tr-plugin-auth 0.0.0-stage → 1.0.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 (60) hide show
  1. package/README.md +45 -2
  2. package/cli.mjs +384 -0
  3. package/package.json +40 -4
  4. package/plugin.json +302 -0
  5. package/src/access-keys.mjs +225 -0
  6. package/src/agent-dir.mjs +3 -0
  7. package/src/browser-session.mjs +240 -0
  8. package/src/cli-secret.mjs +1695 -0
  9. package/src/config.mjs +4 -0
  10. package/src/configuration.mjs +19 -0
  11. package/src/credential-preservation.mjs +28 -0
  12. package/src/crypto.mjs +195 -0
  13. package/src/env-import.mjs +502 -0
  14. package/src/fact-seeker.mjs +3 -0
  15. package/src/frontmatter.mjs +3 -0
  16. package/src/google-oauth.mjs +139 -0
  17. package/src/host-runtime.mjs +7 -0
  18. package/src/leader-election.mjs +9 -0
  19. package/src/logger.mjs +3 -0
  20. package/src/mesh-auth.mjs +7 -0
  21. package/src/mesh-identity.mjs +16 -0
  22. package/src/mesh-secrets.mjs +146 -0
  23. package/src/mesh.mjs +23 -0
  24. package/src/metadata-state.mjs +17 -0
  25. package/src/project-id.mjs +3 -0
  26. package/src/provider-account-sync.mjs +1145 -0
  27. package/src/provider-catalog.mjs +515 -0
  28. package/src/provider-rotation-recipes.mjs +299 -0
  29. package/src/research-gate.mjs +3 -0
  30. package/src/research-queue.mjs +3 -0
  31. package/src/rotation-capability.mjs +189 -0
  32. package/src/routes/auth.mjs +207 -0
  33. package/src/routes/keys.mjs +61 -0
  34. package/src/routes/secrets-runtime.mjs +5 -0
  35. package/src/routes/secrets.mjs +747 -0
  36. package/src/routes/webauthn.mjs +163 -0
  37. package/src/runtime-credentials.mjs +19 -0
  38. package/src/runtime.mjs +3 -0
  39. package/src/secret-integration-research.mjs +340 -0
  40. package/src/secrets-env-diff.mjs +132 -0
  41. package/src/secrets-env-export.mjs +322 -0
  42. package/src/secrets-gateway.mjs +253 -0
  43. package/src/secrets-keychain.mjs +83 -0
  44. package/src/secrets-rekey.mjs +366 -0
  45. package/src/secrets-remote-deploy.mjs +250 -0
  46. package/src/secrets-rotate.mjs +437 -0
  47. package/src/secrets-store.mjs +756 -0
  48. package/src/secrets-sync.mjs +81 -0
  49. package/src/secure-file.mjs +71 -0
  50. package/src/server-auth.mjs +500 -0
  51. package/src/session-credentials.mjs +71 -0
  52. package/src/ssss-store.mjs +13 -0
  53. package/src/stdin.mjs +31 -0
  54. package/src/throttled-fetch.mjs +3 -0
  55. package/src/vault.mjs +3 -0
  56. package/src/vfs-documents.mjs +3 -0
  57. package/src/webauthn-store.mjs +265 -0
  58. package/ui/DESIGN.md +19 -0
  59. package/ui/card.js +274 -0
  60. package/ui/panel.js +338 -0
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
3
+ import { getLeaderInfo, isLeader } from './leader-election.mjs';
4
+ import { logger } from './logger.mjs';
5
+ import { brainDir } from './config.mjs';
6
+ import { resolveSecretsPath, loadSecrets, saveSecrets, ensureMeta, appendAudit, META_KEY } from './secrets-store.mjs';
7
+
8
+ const REQUEST_TIMEOUT_MS = 10_000;
9
+
10
+ function serverPort() {
11
+ const value = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
12
+ return Number.isInteger(value) && value > 0 && value <= 65535 ? value : 3000;
13
+ }
14
+
15
+ function meshUrl(ip, route) {
16
+ const octets = String(ip || '').split('.').map(Number);
17
+ if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255) || octets[0] !== 100 || octets[1] < 64 || octets[1] > 127) {
18
+ throw new Error('Leader IP is outside the Tailscale CGNAT range');
19
+ }
20
+ return `http://${ip}:${serverPort()}${route}`;
21
+ }
22
+
23
+ export function getSecretsChecksum() {
24
+ const filePath = resolveSecretsPath(brainDir);
25
+ if (!fs.existsSync(filePath)) return null;
26
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
27
+ }
28
+
29
+ export function secretsSyncMode(env = process.env) {
30
+ if (env.TR_SECRETS_SYNC_MODE && env.TR_SECRETS_SYNC_MODE !== 'mesh-identity') throw new Error('Only explicit mesh identity grants are supported');
31
+ return 'mesh-identity';
32
+ }
33
+
34
+ /**
35
+ * Grant-based sync: ask the leader which keys this machine may read, fetch
36
+ * only those, and merge them into this node's own encrypted store. Keys that
37
+ * were granted before and are no longer listed are removed (revocation); that
38
+ * only happens after a successful list, so an unreachable leader never wipes
39
+ * anything. The node's store is never replaced wholesale and the node keeps
40
+ * its own master password.
41
+ */
42
+ export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl = fetch } = {}) {
43
+ const base = meshUrl(leaderIp, '/api/secrets/mesh');
44
+ const listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
45
+ if (!listResponse.ok) throw new Error(`Leader returned ${listResponse.status}`);
46
+ const { keys } = await listResponse.json();
47
+ if (!Array.isArray(keys)) throw new Error('Invalid grant list');
48
+
49
+ const fetched = {};
50
+ for (const key of keys) {
51
+ const response = await fetchImpl(`${base}/${encodeURIComponent(key)}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
52
+ if (!response.ok) throw new Error(`Leader returned ${response.status} for ${key}`);
53
+ fetched[key] = (await response.json()).value;
54
+ }
55
+
56
+ const secrets = ensureMeta(await loadSecrets(dir));
57
+ const previous = Array.isArray(secrets[META_KEY].mesh_granted) ? secrets[META_KEY].mesh_granted : [];
58
+ const removed = previous.filter((key) => !(key in fetched) && key in secrets);
59
+ for (const key of removed) {
60
+ delete secrets[key];
61
+ delete secrets[META_KEY].keys?.[key];
62
+ }
63
+ let updated = 0;
64
+ for (const [key, value] of Object.entries(fetched)) {
65
+ if (secrets[key] !== value) updated += 1;
66
+ secrets[key] = value;
67
+ }
68
+ secrets[META_KEY].mesh_granted = Object.keys(fetched);
69
+ await saveSecrets(dir, secrets);
70
+ appendAudit(dir, { action: 'mesh_grant_sync', key: `(${keys.length} granted, ${removed.length} revoked)`, actor: 'mesh-sync' });
71
+ return { granted: keys.length, updated, removed: removed.length };
72
+ }
73
+
74
+ export async function syncLoop() {
75
+ secretsSyncMode();
76
+ if (await isLeader()) return;
77
+ const leaderInfo = await getLeaderInfo();
78
+ if (!leaderInfo?.ip) return;
79
+ try { await syncGrantedSecrets(leaderInfo.ip); }
80
+ catch (err) { logger.error('secrets-sync', 'Failed to sync granted secrets', { error: err.message }); }
81
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Secure file writes for credential-bearing files.
3
+ *
4
+ * `fs.writeFileSync`/`fs.appendFileSync` only apply their `mode` option when the
5
+ * file is CREATED. Rewriting a file that already exists silently preserves the
6
+ * old permissions, so a security.yml or secrets.enc that was ever created under
7
+ * a loose umask (or restored from a backup, or copied by a sync) stays
8
+ * world-readable forever no matter how many times it is rewritten with
9
+ * `{ mode: 0o600 }`.
10
+ *
11
+ * Observed live: config/security.yml at mode 644 while holding the dashboard
12
+ * bcrypt password_hash.
13
+ *
14
+ * Every writer of a file containing hashes, tokens, or key material must go
15
+ * through here so the mode is re-asserted on every write, not just the first.
16
+ */
17
+
18
+ import fs from 'fs';
19
+
20
+ export const SECRET_FILE_MODE = 0o600;
21
+
22
+ /**
23
+ * Re-assert permissions on an existing file.
24
+ *
25
+ * @param {string} filePath
26
+ * @param {number} [mode]
27
+ * @returns {boolean} true when the mode was applied
28
+ */
29
+ export function chmodSecure(filePath, mode = SECRET_FILE_MODE) {
30
+ try {
31
+ fs.chmodSync(filePath, mode);
32
+ return true;
33
+ } catch {
34
+ // Best effort: chmod can fail on non-POSIX filesystems.
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function requireSecureMode(filePath, mode) {
40
+ if (!chmodSecure(filePath, mode)) {
41
+ throw new Error(`Could not secure file permissions: ${filePath}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * writeFileSync that actually enforces `mode` on rewrite.
47
+ *
48
+ * @param {string} filePath
49
+ * @param {string|Buffer} data
50
+ * @param {object} [options] same as fs.writeFileSync; `mode` defaults to 0o600
51
+ */
52
+ export function writeFileSecure(filePath, data, options = {}) {
53
+ const { mode = SECRET_FILE_MODE, ...rest } = options;
54
+ if (fs.existsSync(filePath)) requireSecureMode(filePath, SECRET_FILE_MODE);
55
+ fs.writeFileSync(filePath, data, { ...rest, mode });
56
+ requireSecureMode(filePath, mode);
57
+ }
58
+
59
+ /**
60
+ * appendFileSync that actually enforces `mode` on an existing file.
61
+ *
62
+ * @param {string} filePath
63
+ * @param {string|Buffer} data
64
+ * @param {object} [options] same as fs.appendFileSync; `mode` defaults to 0o600
65
+ */
66
+ export function appendFileSecure(filePath, data, options = {}) {
67
+ const { mode = SECRET_FILE_MODE, ...rest } = options;
68
+ if (fs.existsSync(filePath)) requireSecureMode(filePath, SECRET_FILE_MODE);
69
+ fs.appendFileSync(filePath, data, { ...rest, mode });
70
+ requireSecureMode(filePath, mode);
71
+ }
@@ -0,0 +1,500 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import yaml from 'yaml';
5
+ import rateLimit from 'express-rate-limit';
6
+ import { createSessionCredentials, BCRYPT_COST } from './session-credentials.mjs';
7
+
8
+ export { BCRYPT_COST };
9
+ export function createServerAuth({ brainDir, authBrainDir = brainDir, nodeEnv, watchdog, findValidKeyByToken, keyHasAnyScope, recordKeyUsage, recordKeyOutcome, logger }) {
10
+ const CONFIG_FILE = path.join(brainDir, 'config', 'security.yml');
11
+ const credentials = createSessionCredentials({ brainDir: authBrainDir });
12
+ const signSessionToken = (payload, options) => credentials.signToken('dashboard', payload, options);
13
+ const dashboardAuthState = () => credentials.dashboardState();
14
+ function loadSecurityConfig() {
15
+ if (!fs.existsSync(CONFIG_FILE)) {
16
+ return {
17
+ dashboard: {},
18
+ api: { pats: [], allow_static_pats: false },
19
+ network: { require_https: true, public_health: false, allowed_origins: [], trusted_proxies: [] },
20
+ // No `host` here, deliberately. This default used to say '127.0.0.1',
21
+ // which `resolveServerHost()` cannot tell apart from a user who typed
22
+ // that address on purpose — configuredHost wins over meshIp, so a mesh
23
+ // address was discovered and then thrown away. Every install without a
24
+ // config file bound loopback only, permanently and silently: the brain
25
+ // answered fine on its own console and was invisible to every other
26
+ // device, while the pairing card went on recommending the mesh URL.
27
+ // Leaving it unset lets the precedence do its job — explicit config,
28
+ // else mesh, else loopback. Public binds are still gated by
29
+ // allow_public_bind below.
30
+ bind: { port: 3000, allow_public_bind: false },
31
+ rate_limits: { api_requests_per_minute: 1200 },
32
+ sandbox: { enabled: false }
33
+ };
34
+ }
35
+ const parsed = yaml.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
36
+ if (parsed.sandbox === undefined) {
37
+ parsed.sandbox = { enabled: false };
38
+ }
39
+ if (parsed.dashboard) { delete parsed.dashboard.password_hash; delete parsed.dashboard.force_password_reset; }
40
+ if (parsed.api) { delete parsed.api.pats; delete parsed.api.allow_static_pats; }
41
+ return parsed;
42
+ }
43
+
44
+ function apiRateLimiter() {
45
+ const config = loadSecurityConfig();
46
+ const limit = config.rate_limits?.api_requests_per_minute || 1200;
47
+ return rateLimit({
48
+ windowMs: 60 * 1000,
49
+ max: limit,
50
+ message: { error: 'Too many requests to the API' },
51
+ standardHeaders: true,
52
+ legacyHeaders: false,
53
+ validate: false,
54
+ });
55
+ }
56
+
57
+ /** Public password endpoints must bound expensive bcrypt work independently. */
58
+ function authRateLimiter() {
59
+ const configured = loadSecurityConfig().rate_limits?.auth_requests_per_minute;
60
+ const limit = Number.isInteger(configured) && configured > 0 ? Math.min(configured, 1000) : 20;
61
+ return rateLimit({ windowMs: 60 * 1000, max: limit, standardHeaders: true, legacyHeaders: false,
62
+ message: { error: 'Too many authentication requests' }, validate: false });
63
+ }
64
+
65
+ /**
66
+ * Key the rate limit on the authenticated principal when possible so a
67
+ * single misbehaving PAT can't lock out other keys. Falls back to the
68
+ * client IP otherwise.
69
+ */
70
+ function keyOrIp(req) {
71
+ // findValidKeyByToken populates req.key when requireAuth has already run.
72
+ if (req.key?.id) return `key:${req.key.id}`;
73
+ if (req.user?.id) return `user:${req.user.id}`;
74
+ return `ip:${req.ip || 'unknown'}`;
75
+ }
76
+
77
+ /**
78
+ * Strict per-principal limiter for `POST /api/sandbox`.
79
+ *
80
+ * The sandbox endpoint executes arbitrary Node.js code; even authenticated
81
+ * abuse is expensive. Defaults to 10/min per key/user, overridable via
82
+ * `security.yml.rate_limits.sandbox_requests_per_minute`.
83
+ */
84
+ function sandboxRateLimiter() {
85
+ const config = loadSecurityConfig();
86
+ const limit = config.rate_limits?.sandbox_requests_per_minute || 10;
87
+ return rateLimit({
88
+ windowMs: 60 * 1000,
89
+ max: limit,
90
+ message: { error: 'Too many sandbox executions' },
91
+ standardHeaders: true,
92
+ legacyHeaders: false,
93
+ keyGenerator: keyOrIp,
94
+ validate: false,
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Moderate per-principal limiter for `POST /api/sessions/ingest`.
100
+ *
101
+ * The relay legitimately fires many times during a busy IDE day, so the
102
+ * default is generous. Overridable via
103
+ * `security.yml.rate_limits.ingest_requests_per_minute`.
104
+ */
105
+ function ingestRateLimiter() {
106
+ const config = loadSecurityConfig();
107
+ const limit = config.rate_limits?.ingest_requests_per_minute || 120;
108
+ return rateLimit({
109
+ windowMs: 60 * 1000,
110
+ max: limit,
111
+ message: { error: 'Too many ingest requests' },
112
+ standardHeaders: true,
113
+ legacyHeaders: false,
114
+ keyGenerator: keyOrIp,
115
+ validate: false,
116
+ });
117
+ }
118
+
119
+ function isLoopbackIp(ip) {
120
+ if (!ip) return false;
121
+ const normalized = String(ip)
122
+ .trim()
123
+ .replace(/^"|"$/g, '')
124
+ .replace(/^\[(.*)\]$/, '$1')
125
+ .replace(/^::ffff:/, '')
126
+ .replace(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/, '$1');
127
+ return normalized === '127.0.0.1' || normalized === '::1' || normalized === 'localhost';
128
+ }
129
+
130
+ /**
131
+ * Tailscale/Headscale mesh addresses: IPv4 CGNAT 100.64.0.0/10 and the
132
+ * Tailscale ULA prefix fd7a:115c:a1e0::/48. Traffic on these addresses is
133
+ * already WireGuard-encrypted, so HTTPS is redundant. Only ever check the
134
+ * direct socket address — forwarded headers are spoofable.
135
+ */
136
+ function isMeshIp(ip) {
137
+ if (!ip) return false;
138
+ const normalized = String(ip)
139
+ .trim()
140
+ .replace(/^\[(.*)\]$/, '$1')
141
+ .replace(/^::ffff:/, '');
142
+ if (normalized.toLowerCase().startsWith('fd7a:115c:a1e0:')) return true;
143
+ const m = normalized.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
144
+ if (!m) return false;
145
+ const first = Number(m[1]);
146
+ const second = Number(m[2]);
147
+ return first === 100 && second >= 64 && second <= 127;
148
+ }
149
+
150
+ function forwardedClientIps(req) {
151
+ const ips = [];
152
+ const headerKey = ['x', 'forwarded', 'for'].join('-');
153
+ const xForwardedFor = req.headers?.[headerKey];
154
+ if (typeof xForwardedFor === 'string') {
155
+ ips.push(...xForwardedFor.split(',').map((ip) => ip.trim()).filter(Boolean));
156
+ }
157
+
158
+ const forwarded = req.headers?.forwarded;
159
+ if (typeof forwarded === 'string') {
160
+ for (const part of forwarded.split(',')) {
161
+ const match = part.match(/(?:^|;)\s*for=("[^"]+"|[^;,\s]+)/i);
162
+ if (match) {
163
+ let val = match[1].trim();
164
+ if (val.startsWith('"') && val.endsWith('"')) {
165
+ val = val.slice(1, -1).trim();
166
+ }
167
+ ips.push(val);
168
+ }
169
+ }
170
+ }
171
+
172
+ return ips;
173
+ }
174
+
175
+ function isLocalRequest(req) {
176
+ const remote = req.socket?.remoteAddress;
177
+
178
+ // Inspect forwarded headers first.
179
+ const forwardedIps = forwardedClientIps(req);
180
+ if (forwardedIps.length > 0) {
181
+ // If any forwarded IP is not a loopback IP, this is NOT a local request!
182
+ if (!forwardedIps.every((ip) => isLoopbackIp(ip))) {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ // If there are no non-loopback forwarded IPs, we check if the physical connection is loopback
188
+ if (isLoopbackIp(remote)) {
189
+ return true;
190
+ }
191
+
192
+ // SECURITY: Only trust X-Forwarded-For if the direct caller (remoteAddress)
193
+ // is explicitly configured in security.yml network.trusted_proxies allowlist.
194
+ try {
195
+ const config = loadSecurityConfig();
196
+ const trusted = config.network?.trusted_proxies;
197
+ if (Array.isArray(trusted) && remote) {
198
+ const normalizedRemote = String(remote)
199
+ .trim()
200
+ .replace(/^"|"$/g, '')
201
+ .replace(/^\[(.*)\]$/, '$1')
202
+ .replace(/^::ffff:/, '')
203
+ .replace(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/, '$1');
204
+
205
+ if (trusted.includes(normalizedRemote)) {
206
+ const headerKey = ['x', 'forwarded', 'for'].join('-');
207
+ const xForwardedFor = req.headers?.[headerKey];
208
+ if (typeof xForwardedFor === 'string') {
209
+ const clientIp = xForwardedFor.split(',')[0].trim();
210
+ return isLoopbackIp(clientIp);
211
+ }
212
+ }
213
+ }
214
+ } catch (e) {
215
+ // Suppress config load errors
216
+ }
217
+
218
+ return false;
219
+ }
220
+
221
+ function timingSafeStringEqual(a, b) {
222
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
223
+ const left = Buffer.from(a, 'utf8');
224
+ const right = Buffer.from(b, 'utf8');
225
+ if (left.length !== right.length) return false;
226
+ return crypto.timingSafeEqual(left, right);
227
+ }
228
+
229
+ function requireHttps(req, res, next) {
230
+ const config = loadSecurityConfig();
231
+ if (nodeEnv !== 'production') return next();
232
+ if (config.network?.require_https === false) return next();
233
+ if (isLocalRequest(req)) return next();
234
+ // Mesh traffic is WireGuard-encrypted end-to-end; HTTPS adds nothing.
235
+ // Socket address only — never trust forwarded headers for this exemption.
236
+ if (isMeshIp(req.socket?.remoteAddress) && forwardedClientIps(req).length === 0) return next();
237
+
238
+ // Express derives secure only through its configured trusted proxy boundary.
239
+ if (req.secure) return next();
240
+
241
+ return res.status(426).json({
242
+ error: 'HTTPS is required. Access this brain through its configured TLS endpoint.'
243
+ });
244
+ }
245
+
246
+ function requireAuthOrLocal(req, res, next) {
247
+ const config = loadSecurityConfig();
248
+ if (config.network?.public_health === true) return next();
249
+ if (isLocalRequest(req)) return next();
250
+ // Mesh peers probe /health for latency + LAN discovery. Traffic is already on
251
+ // WireGuard; treat mesh socket addresses like local for this gate only.
252
+ // Never trust X-Forwarded-For for this exemption (same as requireHttps mesh rule).
253
+ if (isMeshIp(req.socket?.remoteAddress) && forwardedClientIps(req).length === 0) {
254
+ return next();
255
+ }
256
+ return requireAuth(req, res, next);
257
+ }
258
+
259
+ function corsOptions() {
260
+ const config = loadSecurityConfig();
261
+ const allowed = new Set(config.network?.allowed_origins || []);
262
+ return {
263
+ credentials: true,
264
+ origin(origin, callback) {
265
+ if (!origin) return callback(null, false);
266
+ if (allowed.has(origin)) return callback(null, origin);
267
+ // Automatically allow loopback/localhost origins for local tools/setup compatibility
268
+ try {
269
+ const parsed = new URL(origin);
270
+ const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]';
271
+ const allowedPorts = ['3000', '3001', '5173', '5174', '8080', '8888'];
272
+ if (isLocalhost && (!parsed.port || allowedPorts.includes(parsed.port))) {
273
+ return callback(null, origin);
274
+ }
275
+ } catch (e) {}
276
+ return callback(null, false);
277
+ }
278
+ };
279
+ }
280
+
281
+
282
+ // Global authentication middleware
283
+ function requireAuth(req, res, next) {
284
+ const ip = req.ip || req.socket?.remoteAddress;
285
+ if (ip && watchdog.isIpBlocked(ip)) {
286
+ return res.status(403).json({ error: 'IP blocked due to too many failed auth attempts.' });
287
+ }
288
+
289
+ const config = loadSecurityConfig();
290
+
291
+ // 1. Check Bearer PAT — validated against hashed keys.jsonl entries.
292
+ const authHeader = req.headers.authorization;
293
+ if (authHeader && authHeader.startsWith('Bearer ')) {
294
+ const token = authHeader.substring(7);
295
+ // Check dynamic keys store first
296
+ const key = findValidKeyByToken(token);
297
+ if (key) {
298
+ recordKeyUsage(token, { method: req.method, path: req.path });
299
+ if (recordKeyOutcome && typeof res.once === 'function') res.once('finish', () => {
300
+ try { recordKeyOutcome(key.id, { method: req.method, path: req.path, status: res.statusCode }); }
301
+ catch { logger.error('auth', 'PAT request outcome could not be recorded', { key_id: key.id }); }
302
+ });
303
+ req.auth = {
304
+ type: 'pat',
305
+ key_id: key.id,
306
+ key_name: key.name,
307
+ id: key.id,
308
+ role: key.role || null,
309
+ expires_at: key.expires_at,
310
+ scopes: key.scopes || []
311
+ };
312
+ return next();
313
+ }
314
+ if (ip) watchdog.recordAuthFailure(ip);
315
+ return res.status(401).json({ error: 'Invalid PAT' });
316
+ }
317
+
318
+ // 2. Check Cookie Session
319
+ const sessionToken = req.cookies?.session;
320
+ if (sessionToken) {
321
+ try {
322
+ credentials.verifyToken('dashboard', sessionToken);
323
+ req.auth = { type: 'dashboard_session', scopes: ['*'] };
324
+ return next();
325
+ } catch (err) {
326
+ return res.status(401).json({ error: 'Invalid or expired session' });
327
+ }
328
+ }
329
+
330
+ // If running locally, maybe we can bypass? PRD says:
331
+ // "All endpoints require authentication (except /health on localhost)"
332
+ // So authentication is strictly required.
333
+
334
+ return res.status(401).json({ error: 'Authentication required. Provide Bearer token or login via dashboard.' });
335
+ }
336
+
337
+ function requireScope(...requiredScopes) {
338
+ return (req, res, next) => {
339
+ if (!requiredScopes.length) return next();
340
+ if (req.auth?.type === 'dashboard_session') return next();
341
+ if (keyHasAnyScope(req.auth || { scopes: [] }, requiredScopes)) return next();
342
+ return res.status(403).json({
343
+ error: 'Insufficient token scope',
344
+ required_scopes: requiredScopes
345
+ });
346
+ };
347
+ }
348
+
349
+ function requireSandboxEnabled(req, res, next) {
350
+ const config = loadSecurityConfig();
351
+ if (!config.sandbox?.enabled) {
352
+ logger.warn('Sandbox request rejected: sandbox is disabled in security.yml', { ip: req.ip });
353
+ return res.status(403).json({ error: 'Sandbox is disabled in security.yml' });
354
+ }
355
+ logger.info('Executing sandbox payload (sandbox is explicitly enabled in security.yml)', { ip: req.ip });
356
+ next();
357
+ }
358
+
359
+ /**
360
+ * Cookie options for the dashboard session.
361
+ *
362
+ * `secure` has to describe the connection that is actually in use, not the
363
+ * connection the config wishes for. It was previously derived from
364
+ * `nodeEnv !== 'development' && require_https !== false`, and since nodeEnv
365
+ * defaults to 'production' and the server only ever calls app.listen() — there
366
+ * is no HTTPS listener anywhere in it — every login over the mesh bind issued a
367
+ * `Secure` cookie on a plain-HTTP response. Browsers drop those, so login
368
+ * returned 200, no session was stored, and the next request 401'd straight back
369
+ * to the login page. http://localhost is exempt as a trustworthy origin, which
370
+ * is why this looked fine locally and only bit when reaching the dashboard by
371
+ * its host or mesh IP.
372
+ *
373
+ * Express resolves `req.secure` through `trust proxy`, so a deployment behind
374
+ * an HTTPS-terminating reverse proxy still gets `Secure` set.
375
+ */
376
+ function sessionCookieOptions(req, maxAge) {
377
+ const config = loadSecurityConfig();
378
+ const isSecureConnection = req?.secure === true;
379
+
380
+ if (config.network?.require_https !== false && !isSecureConnection) {
381
+ logger.warn('auth',
382
+ 'require_https is set but this request arrived over plain HTTP; issuing the session '
383
+ + 'cookie without Secure so login works. Terminate TLS in front of the dashboard, or '
384
+ + 'set network.require_https: false to silence this.');
385
+ }
386
+
387
+ return {
388
+ httpOnly: true,
389
+ secure: isSecureConnection,
390
+ maxAge,
391
+ sameSite: 'lax',
392
+ };
393
+ }
394
+
395
+ async function loginHandler(req, res) {
396
+ const ip = req.ip || req.socket?.remoteAddress;
397
+ if (ip && watchdog.isIpBlocked(ip)) {
398
+ return res.status(403).json({ error: 'IP blocked due to too many failed auth attempts.' });
399
+ }
400
+
401
+ const { password } = req.body || {};
402
+ if (typeof password !== 'string' || !password || password.length > 4096) {
403
+ return res.status(400).json({ error: 'Password is required' });
404
+ }
405
+
406
+ const config = loadSecurityConfig();
407
+ const passwordConfigured = credentials.dashboardState().configured;
408
+
409
+ if (!passwordConfigured) {
410
+ return res.status(403).json({ error: 'Dashboard login is not configured in security.yml' });
411
+ }
412
+
413
+ const match = await credentials.verifyDashboardPassword(password);
414
+ if (!match) {
415
+ if (ip) watchdog.recordAuthFailure(ip);
416
+ return res.status(401).json({ error: 'Invalid password' });
417
+ }
418
+
419
+ if (ip) watchdog.resetAuthFailures(ip);
420
+
421
+ const ttlHours = config.dashboard?.session_ttl_hours || 24;
422
+ const token = signSessionToken({ user: 'admin' }, { ttlSeconds: ttlHours * 3600 });
423
+
424
+ res.cookie('session', token, sessionCookieOptions(req, ttlHours * 60 * 60 * 1000));
425
+
426
+
427
+ const requiresReset = credentials.dashboardState().forcePasswordReset;
428
+ res.json({ success: true, requiresPasswordReset: requiresReset });
429
+ }
430
+
431
+ async function changePasswordHandler(req, res) {
432
+ const { newPassword } = req.body || {};
433
+ if (typeof newPassword !== 'string' || newPassword.length < 8 || newPassword.length > 4096) {
434
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
435
+ }
436
+
437
+ await credentials.setDashboardPassword(newPassword);
438
+
439
+ res.json({ success: true });
440
+ }
441
+
442
+ /**
443
+ * Short-lived step-up token for high-risk actions (secret reveal / copy).
444
+ * Requires a fresh passkey assertion or password re-entry to mint.
445
+ *
446
+ * @param {{ purpose?: string, ttlSeconds?: number, actor?: string }} opts
447
+ */
448
+ function mintStepUpToken(opts = {}) {
449
+ const purpose = opts.purpose || 'secrets:reveal';
450
+ const ttl = Math.min(300, Math.max(15, opts.ttlSeconds || 60));
451
+ return credentials.signToken('step-up',
452
+ {
453
+ purpose,
454
+ step_up: true,
455
+ actor: opts.actor || 'dashboard',
456
+ },
457
+ { ttlSeconds: ttl },
458
+ );
459
+ }
460
+
461
+ /**
462
+ * @param {string} token
463
+ * @param {string} expectedPurpose
464
+ * @returns {{ ok: true, payload: object } | { ok: false, error: string }}
465
+ */
466
+ function verifyStepUpToken(token, expectedPurpose = 'secrets:reveal') {
467
+ if (!token || typeof token !== 'string') {
468
+ return { ok: false, error: 'Step-up token required' };
469
+ }
470
+ try {
471
+ const payload = credentials.verifyToken('step-up', token);
472
+ if (!payload?.step_up || payload.purpose !== expectedPurpose) {
473
+ return { ok: false, error: 'Invalid step-up purpose' };
474
+ }
475
+ return { ok: true, payload };
476
+ } catch {
477
+ return { ok: false, error: 'Step-up token invalid or expired — re-authenticate with passkey' };
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Re-check dashboard password (step-up when no passkey registered).
483
+ */
484
+ async function verifyDashboardPassword(password) {
485
+ if (!password) return false;
486
+ const config = loadSecurityConfig();
487
+ const passwordConfigured = credentials.dashboardState().configured;
488
+ if (!passwordConfigured) return false;
489
+ return credentials.verifyDashboardPassword(password);
490
+ }
491
+
492
+ function logoutHandler(req, res) {
493
+ // Attributes must match those the cookie was set with or the browser keeps it.
494
+ const { maxAge, ...clearOptions } = sessionCookieOptions(req, 0);
495
+ res.clearCookie('session', clearOptions);
496
+ res.json({ success: true });
497
+ }
498
+
499
+ return { loadSecurityConfig, apiRateLimiter, authRateLimiter, sandboxRateLimiter, ingestRateLimiter, isLoopbackIp, isMeshIp, isLocalRequest, requireHttps, requireAuthOrLocal, corsOptions, requireAuth, requireScope, requireSandboxEnabled, sessionCookieOptions, loginHandler, changePasswordHandler, mintStepUpToken, verifyStepUpToken, verifyDashboardPassword, logoutHandler, signSessionToken, dashboardAuthState };
500
+ }