@livedesk/hub 0.1.39 → 0.1.40

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -0,0 +1,119 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export const HUB_UI_SESSION_COOKIE = 'livedesk_hub_ui';
4
+ export const HUB_UI_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
5
+
6
+ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
7
+
8
+ function secureEqual(left, right) {
9
+ const leftBuffer = Buffer.from(String(left || ''), 'utf8');
10
+ const rightBuffer = Buffer.from(String(right || ''), 'utf8');
11
+ return leftBuffer.length === rightBuffer.length
12
+ && leftBuffer.length > 0
13
+ && crypto.timingSafeEqual(leftBuffer, rightBuffer);
14
+ }
15
+
16
+ function parseCookies(value) {
17
+ const result = new Map();
18
+ for (const part of String(value || '').split(';')) {
19
+ const separator = part.indexOf('=');
20
+ if (separator <= 0) continue;
21
+ const key = part.slice(0, separator).trim();
22
+ const rawValue = part.slice(separator + 1).trim();
23
+ if (!key) continue;
24
+ try {
25
+ result.set(key, decodeURIComponent(rawValue));
26
+ } catch {
27
+ result.set(key, rawValue);
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+
33
+ function randomToken() {
34
+ return crypto.randomBytes(32).toString('base64url');
35
+ }
36
+
37
+ export function createHubUiSessionAuthority({
38
+ ttlMs = HUB_UI_SESSION_TTL_MS,
39
+ maxSessions = 32,
40
+ now = () => Date.now()
41
+ } = {}) {
42
+ const sessions = new Map();
43
+
44
+ function purgeExpired() {
45
+ const current = now();
46
+ for (const [sessionId, session] of sessions) {
47
+ if (session.expiresAt <= current) sessions.delete(sessionId);
48
+ }
49
+ while (sessions.size >= maxSessions) {
50
+ sessions.delete(sessions.keys().next().value);
51
+ }
52
+ }
53
+
54
+ function issue(userId) {
55
+ const normalizedUserId = String(userId || '').trim();
56
+ if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
57
+ purgeExpired();
58
+ const sessionId = randomToken();
59
+ const csrfToken = randomToken();
60
+ const expiresAt = now() + ttlMs;
61
+ sessions.set(sessionId, { userId: normalizedUserId, csrfToken, expiresAt });
62
+ return { sessionId, csrfToken, expiresAt };
63
+ }
64
+
65
+ function authorize(req, currentUserId, {
66
+ requireCsrf = !SAFE_HTTP_METHODS.has(String(req.method || 'GET').toUpperCase())
67
+ } = {}) {
68
+ purgeExpired();
69
+ const sessionId = parseCookies(req.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
70
+ if (!sessionId) {
71
+ return { ok: false, status: 401, error: 'hub-ui-session-required' };
72
+ }
73
+ const session = sessions.get(sessionId);
74
+ if (!session) {
75
+ return { ok: false, status: 401, error: 'hub-ui-session-expired' };
76
+ }
77
+ const normalizedUserId = String(currentUserId || '').trim();
78
+ if (!normalizedUserId || session.userId !== normalizedUserId) {
79
+ sessions.delete(sessionId);
80
+ return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
81
+ }
82
+ if (requireCsrf && !secureEqual(req.headers?.['x-livedesk-csrf'], session.csrfToken)) {
83
+ return { ok: false, status: 403, error: 'hub-ui-csrf-invalid' };
84
+ }
85
+ return { ok: true, sessionId, userId: session.userId, expiresAt: session.expiresAt };
86
+ }
87
+
88
+ return {
89
+ issue,
90
+ authorize,
91
+ revokeAll() {
92
+ sessions.clear();
93
+ },
94
+ sessionCount: () => sessions.size
95
+ };
96
+ }
97
+
98
+ export function serializeHubUiSessionCookie(sessionId, expiresAt, { secure = false } = {}) {
99
+ const maxAgeSeconds = Math.max(1, Math.floor((Number(expiresAt || 0) - Date.now()) / 1000));
100
+ return [
101
+ `${HUB_UI_SESSION_COOKIE}=${encodeURIComponent(String(sessionId || ''))}`,
102
+ 'Path=/',
103
+ 'HttpOnly',
104
+ 'SameSite=Strict',
105
+ `Max-Age=${maxAgeSeconds}`,
106
+ secure ? 'Secure' : ''
107
+ ].filter(Boolean).join('; ');
108
+ }
109
+
110
+ export function clearHubUiSessionCookie({ secure = false } = {}) {
111
+ return [
112
+ `${HUB_UI_SESSION_COOKIE}=`,
113
+ 'Path=/',
114
+ 'HttpOnly',
115
+ 'SameSite=Strict',
116
+ 'Max-Age=0',
117
+ secure ? 'Secure' : ''
118
+ ].filter(Boolean).join('; ');
119
+ }
package/src/server.js CHANGED
@@ -51,8 +51,13 @@ import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-cor
51
51
  import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
52
52
  import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '../../runtime-core/src/auth-config.js';
53
53
  import { createHubRuntime } from './runtime/hub-runtime.js';
54
- import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
55
- import { roleGuardResponse } from './http/hub-role-guard.js';
54
+ import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
55
+ import { roleGuardResponse } from './http/hub-role-guard.js';
56
+ import {
57
+ clearHubUiSessionCookie,
58
+ createHubUiSessionAuthority,
59
+ serializeHubUiSessionCookie
60
+ } from './http/hub-ui-session.js';
56
61
 
57
62
  const __dirname = dirname(fileURLToPath(import.meta.url));
58
63
  const webDistCandidates = [
@@ -182,9 +187,10 @@ const persistentSessionGcToken = persistentSessionGcEnabled
182
187
  ? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
183
188
  : '';
184
189
  let connectedDeviceCount = 0;
185
- let runtimeAccessToken = '';
186
- let runtimeRefreshToken = '';
187
- let runtimeAccessTokenExpiresAt = 0;
190
+ let runtimeAccessToken = '';
191
+ let runtimeRefreshToken = '';
192
+ let runtimeAccessTokenExpiresAt = 0;
193
+ const hubUiSessionAuthority = createHubUiSessionAuthority();
188
194
  let roleWatchInFlight = false;
189
195
  let verifiedLicense = {
190
196
  userId: '',
@@ -485,11 +491,12 @@ function clearPersistedRuntimeSession() {
485
491
  return writePrivateRuntimeAuthState(state);
486
492
  }
487
493
 
488
- function clearRuntimeSession() {
489
- runtimeAccessToken = '';
490
- runtimeRefreshToken = '';
491
- runtimeAccessTokenExpiresAt = 0;
492
- runtimeManager.setAuthenticated(false);
494
+ function clearRuntimeSession() {
495
+ runtimeAccessToken = '';
496
+ runtimeRefreshToken = '';
497
+ runtimeAccessTokenExpiresAt = 0;
498
+ hubUiSessionAuthority.revokeAll();
499
+ runtimeManager.setAuthenticated(false);
493
500
  try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
494
501
  }
495
502
 
@@ -1405,7 +1412,7 @@ function requestHostname(req) {
1405
1412
  }
1406
1413
  }
1407
1414
 
1408
- function isTrustedBrowserRequest(req) {
1415
+ function isTrustedBrowserRequest(req) {
1409
1416
  const origin = String(req.headers.origin || '').trim();
1410
1417
  if (!origin) {
1411
1418
  const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase();
@@ -1422,22 +1429,59 @@ function isTrustedBrowserRequest(req) {
1422
1429
  const targetHost = requestHostname(req);
1423
1430
  return (originHost === targetHost && isPrivateHubHostname(targetHost))
1424
1431
  || (isLoopbackHostname(originHost) && isLoopbackHostname(targetHost));
1425
- } catch {
1426
- return false;
1427
- }
1428
- }
1429
-
1430
- app.use((req, res, next) => {
1432
+ } catch {
1433
+ return false;
1434
+ }
1435
+ }
1436
+
1437
+ function isSecureHubHttpRequest(req) {
1438
+ return req.socket?.encrypted === true
1439
+ || String(req.headers['x-forwarded-proto'] || '').trim().toLowerCase() === 'https';
1440
+ }
1441
+
1442
+ function currentBoundHubUserId() {
1443
+ return String(
1444
+ runtimeManager.getSnapshot().userId
1445
+ || readPersistedRuntimeSession()?.user?.id
1446
+ || ''
1447
+ ).trim();
1448
+ }
1449
+
1450
+ function isPublicHubApiRequest(req) {
1451
+ const method = String(req.method || 'GET').toUpperCase();
1452
+ const path = String(req.path || '');
1453
+ return (method === 'GET' && (path === '/api/health' || path === '/api/runtime/status' || path === '/api/auth/status'))
1454
+ || (method === 'POST' && path === '/api/auth/session');
1455
+ }
1456
+
1457
+ function isTrustedNativeLoopbackRequest(req) {
1458
+ return isLoopbackAddress(req.socket?.remoteAddress)
1459
+ && !String(req.headers.origin || '').trim()
1460
+ && !String(req.headers['sec-fetch-site'] || '').trim();
1461
+ }
1462
+
1463
+ function authorizeHubUiRequest(req) {
1464
+ if (process.env.LIVEDESK_TEST_MODE === '1') {
1465
+ return { ok: true, explicitTestMode: true };
1466
+ }
1467
+ if (isTrustedNativeLoopbackRequest(req)) {
1468
+ return { ok: true, nativeLoopback: true };
1469
+ }
1470
+ return hubUiSessionAuthority.authorize(req, currentBoundHubUserId());
1471
+ }
1472
+
1473
+ app.use((req, res, next) => {
1431
1474
  if (!isTrustedBrowserRequest(req)) {
1432
1475
  res.status(403).json({ ok: false, error: 'untrusted-hub-origin' });
1433
1476
  return;
1434
1477
  }
1435
1478
  const origin = String(req.headers.origin || '').trim();
1436
1479
  if (origin) {
1437
- res.setHeader('Access-Control-Allow-Origin', origin);
1438
- res.setHeader('Access-Control-Allow-Private-Network', 'true');
1480
+ res.setHeader('Access-Control-Allow-Origin', origin);
1481
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
1482
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
1439
1483
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
1440
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
1484
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-CSRF');
1441
1485
  res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
1442
1486
  }
1443
1487
  if (req.method === 'OPTIONS') {
@@ -1446,8 +1490,21 @@ app.use((req, res, next) => {
1446
1490
  }
1447
1491
  next();
1448
1492
  });
1449
- app.use(express.json({ limit: '32mb' }));
1450
- app.use((req, res, next) => {
1493
+ app.use(express.json({ limit: '32mb' }));
1494
+ app.use((req, res, next) => {
1495
+ if (!req.path.startsWith('/api/') || isPublicHubApiRequest(req)) {
1496
+ next();
1497
+ return;
1498
+ }
1499
+ const authorization = authorizeHubUiRequest(req);
1500
+ if (!authorization.ok) {
1501
+ noStore(res);
1502
+ res.status(authorization.status).json({ ok: false, error: authorization.error });
1503
+ return;
1504
+ }
1505
+ next();
1506
+ });
1507
+ app.use((req, res, next) => {
1451
1508
  if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
1452
1509
  res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
1453
1510
  return;
@@ -4351,10 +4408,19 @@ app.post('/api/auth/session', async (req, res) => {
4351
4408
  res.status(400).json({ ok: false, error: normalized.error });
4352
4409
  return;
4353
4410
  }
4354
- const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4355
- try {
4356
- const user = await verifySupabaseUser(accessToken);
4357
- runtimeAccessToken = accessToken;
4411
+ const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4412
+ try {
4413
+ const user = await verifySupabaseUser(accessToken);
4414
+ const boundUserId = currentBoundHubUserId();
4415
+ if (boundUserId && boundUserId !== user.id) {
4416
+ res.status(403).json({ ok: false, error: 'hub-account-mismatch' });
4417
+ return;
4418
+ }
4419
+ if (!boundUserId && !isLoopbackAddress(req.socket?.remoteAddress)) {
4420
+ res.status(403).json({ ok: false, error: 'hub-first-account-binding-must-be-local' });
4421
+ return;
4422
+ }
4423
+ runtimeAccessToken = accessToken;
4358
4424
  runtimeRefreshToken = refreshToken;
4359
4425
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4360
4426
  runtimeManager.setAuthenticated(true, user.id);
@@ -4367,10 +4433,25 @@ app.post('/api/auth/session', async (req, res) => {
4367
4433
  const hostTarget = runtimeRole === 'hub'
4368
4434
  ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
4369
4435
  : { ok: true, active: false };
4370
- if (runtimeRole === 'hub') {
4371
- startHubHostTargetLeaseRenewal();
4372
- }
4373
- res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4436
+ if (runtimeRole === 'hub') {
4437
+ startHubHostTargetLeaseRenewal();
4438
+ }
4439
+ const uiSession = hubUiSessionAuthority.issue(user.id);
4440
+ res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
4441
+ secure: isSecureHubHttpRequest(req)
4442
+ }));
4443
+ res.json({
4444
+ ok: true,
4445
+ authenticated: true,
4446
+ persisted,
4447
+ userId: user.id,
4448
+ role: runtimeRole,
4449
+ hostTarget,
4450
+ uiSession: {
4451
+ csrfToken: uiSession.csrfToken,
4452
+ expiresAt: uiSession.expiresAt
4453
+ }
4454
+ });
4374
4455
  } catch (error) {
4375
4456
  const message = error instanceof Error ? error.message : String(error);
4376
4457
  const status = authVerificationHttpStatus(message);
@@ -4666,14 +4747,15 @@ function startHubHostTargetLeaseRenewal() {
4666
4747
  hubHostTargetRenewTimer.unref?.();
4667
4748
  }
4668
4749
 
4669
- app.delete('/api/auth/session', async (_req, res) => {
4750
+ app.delete('/api/auth/session', async (req, res) => {
4670
4751
  noStore(res);
4671
4752
  const hostTarget = runtimeRole === 'hub'
4672
4753
  ? await clearHubHostTarget('logout')
4673
4754
  : { ok: true, active: false };
4674
- clearRuntimeSession();
4675
- res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4676
- });
4755
+ clearRuntimeSession();
4756
+ res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
4757
+ res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4758
+ });
4677
4759
 
4678
4760
  app.get('/api/hub/status', (_req, res) => {
4679
4761
  noStore(res);
@@ -5474,13 +5556,20 @@ if (existsSync(webIndexPath)) {
5474
5556
  });
5475
5557
  }
5476
5558
 
5477
- httpServer.on('upgrade', (req, socket, head) => {
5478
- if (!isTrustedBrowserRequest(req)) {
5479
- socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
5480
- socket.destroy();
5481
- return;
5482
- }
5483
- try {
5559
+ httpServer.on('upgrade', (req, socket, head) => {
5560
+ if (!isTrustedBrowserRequest(req)) {
5561
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
5562
+ socket.destroy();
5563
+ return;
5564
+ }
5565
+ const authorization = authorizeHubUiRequest(req);
5566
+ if (!authorization.ok) {
5567
+ const label = authorization.status === 403 ? 'Forbidden' : 'Unauthorized';
5568
+ socket.write(`HTTP/1.1 ${authorization.status} ${label}\r\nConnection: close\r\n\r\n`);
5569
+ socket.destroy();
5570
+ return;
5571
+ }
5572
+ try {
5484
5573
  const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
5485
5574
  if (parsed.pathname === '/api/remote/frames/ws') {
5486
5575
  frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));