@livedesk/hub 0.1.58 → 0.1.59
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 +1 -1
- package/src/auth/workspace-access.js +176 -0
- package/src/console-direct.js +329 -10
- package/src/console-direct.test.mjs +332 -3
- package/src/http/hub-ui-session.js +155 -32
- package/src/server.js +1141 -328
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
encodeDirectConsoleWireMessage
|
|
9
9
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
10
10
|
import { consoleDirectContract, createHubConsoleDirect } from './console-direct.js';
|
|
11
|
+
import { workspaceRoleCanRequest } from './auth/workspace-access.js';
|
|
11
12
|
|
|
12
13
|
assert.equal(consoleDirectContract.maxDefaultRetryDelayMs, 20_000);
|
|
13
14
|
assert.equal(consoleDirectContract.peerDisconnectedTimeoutMs, 5_000);
|
|
@@ -23,6 +24,10 @@ const CONNECTION_ONE = '33333333-3333-4333-8333-333333333333';
|
|
|
23
24
|
const CONNECTION_TWO = '44444444-4444-4444-8444-444444444444';
|
|
24
25
|
const CHANNEL_ID = '55555555-5555-4555-8555-555555555555';
|
|
25
26
|
const REQUEST_ID = '66666666-6666-4666-8666-666666666666';
|
|
27
|
+
const TEAM_WORKSPACE_ID = '88888888-8888-4888-8888-888888888888';
|
|
28
|
+
const TEAM_OWNER_USER_ID = '99999999-9999-4999-8999-999999999999';
|
|
29
|
+
const TEAM_OPERATOR_USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
|
30
|
+
const CONSOLE_PROXY_TOKEN = 'test-console-proxy-token';
|
|
26
31
|
|
|
27
32
|
class FakeSocket extends EventEmitter {
|
|
28
33
|
static CONNECTING = 0;
|
|
@@ -225,6 +230,8 @@ async function connectedDirect(options = {}) {
|
|
|
225
230
|
httpBaseUrl: 'http://127.0.0.1:5179',
|
|
226
231
|
stunUrls: options.stunUrls || ['stun:stun.example.test:3478'],
|
|
227
232
|
getAccessToken: async () => 'access-token',
|
|
233
|
+
...(options.workspaceAccess ? { getWorkspaceAccess: () => options.workspaceAccess } : {}),
|
|
234
|
+
...(options.consoleProxyToken ? { consoleProxyToken: options.consoleProxyToken } : {}),
|
|
228
235
|
SignalingWebSocketImpl: FakeSignalSocket,
|
|
229
236
|
LocalWebSocketImpl: FakeLocalSocket,
|
|
230
237
|
createPeerConnection: (name, config) => new FakePeerConnection(name, config),
|
|
@@ -263,18 +270,23 @@ async function connectedDirect(options = {}) {
|
|
|
263
270
|
type: 'signal-ready',
|
|
264
271
|
role: 'hub',
|
|
265
272
|
deviceId: 'hub-device',
|
|
266
|
-
hubEpoch: HUB_EPOCH
|
|
273
|
+
hubEpoch: HUB_EPOCH,
|
|
274
|
+
...(options.workspaceAccess ? {
|
|
275
|
+
workspaceId: options.workspaceAccess.workspaceId,
|
|
276
|
+
workspaceKind: options.workspaceAccess.workspaceKind
|
|
277
|
+
} : {})
|
|
267
278
|
}), false);
|
|
268
279
|
return { direct, signal };
|
|
269
280
|
}
|
|
270
281
|
|
|
271
|
-
function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer\r\n') {
|
|
282
|
+
function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer\r\n', extra = {}) {
|
|
272
283
|
signal.receive(JSON.stringify({
|
|
273
284
|
type: 'rtc-offer',
|
|
274
285
|
consoleId: CONSOLE_ID,
|
|
275
286
|
connectionId,
|
|
276
287
|
hubEpoch: HUB_EPOCH,
|
|
277
|
-
description: { type: 'offer', sdp }
|
|
288
|
+
description: { type: 'offer', sdp },
|
|
289
|
+
...extra
|
|
278
290
|
}), false);
|
|
279
291
|
return FakePeerConnection.instances.at(-1);
|
|
280
292
|
}
|
|
@@ -615,6 +627,323 @@ test('reliable control channel bridges bounded HTTP over fragmented wire message
|
|
|
615
627
|
direct.close();
|
|
616
628
|
});
|
|
617
629
|
|
|
630
|
+
test('Team Operator can use Wall and Control but cannot mutate the Hub owner runtime', async () => {
|
|
631
|
+
const teamWorkspaceAccess = {
|
|
632
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
633
|
+
workspaceKind: 'team',
|
|
634
|
+
role: 'owner',
|
|
635
|
+
membershipRevision: 7
|
|
636
|
+
};
|
|
637
|
+
const fetched = [];
|
|
638
|
+
const { direct, signal } = await connectedDirect({
|
|
639
|
+
workspaceAccess: teamWorkspaceAccess,
|
|
640
|
+
consoleProxyToken: CONSOLE_PROXY_TOKEN,
|
|
641
|
+
fetchImpl: async (url, init) => {
|
|
642
|
+
fetched.push({ url: String(url), init });
|
|
643
|
+
return new Response('{"ok":true}', {
|
|
644
|
+
status: 200,
|
|
645
|
+
headers: { 'Content-Type': 'application/json' }
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
assert.equal(new URL(signal.url).searchParams.get('workspaceId'), TEAM_WORKSPACE_ID);
|
|
650
|
+
const hubChallengeNonce = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
|
651
|
+
signal.receive(JSON.stringify({
|
|
652
|
+
type: 'workspace-reauth-required',
|
|
653
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
654
|
+
challengeNonce: hubChallengeNonce,
|
|
655
|
+
deadlineAt: Date.now() + 15_000
|
|
656
|
+
}), false);
|
|
657
|
+
await waitFor(() => fetched.some(call => call.url.endsWith('/v2/rtc/reauth')));
|
|
658
|
+
const hubReauth = fetched.find(call => call.url.endsWith('/v2/rtc/reauth'));
|
|
659
|
+
assert.equal(hubReauth.init.headers.Authorization, 'Bearer access-token');
|
|
660
|
+
assert.deepEqual(JSON.parse(hubReauth.init.body), {
|
|
661
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
662
|
+
challengeNonce: hubChallengeNonce
|
|
663
|
+
});
|
|
664
|
+
assert.equal(
|
|
665
|
+
signalingMessages(signal).some(message => JSON.stringify(message).includes('access-token')),
|
|
666
|
+
false,
|
|
667
|
+
'A bearer token must never be sent through the signaling WebSocket.'
|
|
668
|
+
);
|
|
669
|
+
fetched.length = 0;
|
|
670
|
+
const accessExpiresAt = Date.now() + 60_000;
|
|
671
|
+
const peer = offer(signal, CONNECTION_ONE, 'v=0\r\na=fake-offer\r\n', {
|
|
672
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
673
|
+
workspaceKind: 'team',
|
|
674
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
675
|
+
memberRole: 'operator',
|
|
676
|
+
membershipRevision: 7,
|
|
677
|
+
accessExpiresAt
|
|
678
|
+
});
|
|
679
|
+
assert.ok(peer);
|
|
680
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
681
|
+
peer.emitDataChannel(control);
|
|
682
|
+
control.open();
|
|
683
|
+
|
|
684
|
+
sendWire(control, {
|
|
685
|
+
type: 'http-request',
|
|
686
|
+
requestId: CHANNEL_ID,
|
|
687
|
+
method: 'POST',
|
|
688
|
+
path: '/api/auth/session',
|
|
689
|
+
headers: { 'content-type': 'application/json' },
|
|
690
|
+
bodyBase64: Buffer.from(JSON.stringify({ workspaceId: TEAM_WORKSPACE_ID })).toString('base64')
|
|
691
|
+
}, { messageId: 30 });
|
|
692
|
+
await settle();
|
|
693
|
+
assert.equal(fetched.length, 1);
|
|
694
|
+
assert.equal(fetched[0].url, 'http://127.0.0.1:5179/api/auth/session');
|
|
695
|
+
assert.deepEqual(JSON.parse(Buffer.from(fetched[0].init.body).toString('utf8')), {
|
|
696
|
+
workspaceId: TEAM_WORKSPACE_ID
|
|
697
|
+
});
|
|
698
|
+
assert.equal(fetched[0].init.headers.Authorization, undefined);
|
|
699
|
+
fetched.length = 0;
|
|
700
|
+
|
|
701
|
+
sendWire(control, {
|
|
702
|
+
type: 'http-request',
|
|
703
|
+
requestId: REQUEST_ID,
|
|
704
|
+
method: 'POST',
|
|
705
|
+
path: '/api/update/apply'
|
|
706
|
+
}, { messageId: 31 });
|
|
707
|
+
await settle();
|
|
708
|
+
let responses = decodeWireMessages(control.sent).map(message => JSON.parse(message.data));
|
|
709
|
+
const forbidden = responses.find(message => message.requestId === REQUEST_ID);
|
|
710
|
+
assert.equal(forbidden?.status, 403);
|
|
711
|
+
assert.equal(forbidden?.error, 'workspace-owner-required');
|
|
712
|
+
assert.equal(fetched.length, 0, 'A denied Operator request must never reach the local Hub API.');
|
|
713
|
+
|
|
714
|
+
sendWire(control, {
|
|
715
|
+
type: 'http-request',
|
|
716
|
+
requestId: HUB_EPOCH_TWO,
|
|
717
|
+
method: 'POST',
|
|
718
|
+
path: '/api/remote/devices/client-1/input',
|
|
719
|
+
headers: { 'content-type': 'application/json' },
|
|
720
|
+
bodyBase64: Buffer.from('{"type":"pointermove"}').toString('base64')
|
|
721
|
+
}, { messageId: 32 });
|
|
722
|
+
await settle();
|
|
723
|
+
assert.equal(fetched.length, 1);
|
|
724
|
+
assert.equal(fetched[0].url, 'http://127.0.0.1:5179/api/remote/devices/client-1/input');
|
|
725
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Console-Proxy'], CONSOLE_PROXY_TOKEN);
|
|
726
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-Id'], TEAM_WORKSPACE_ID);
|
|
727
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-User-Id'], TEAM_OPERATOR_USER_ID);
|
|
728
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-Role'], 'operator');
|
|
729
|
+
responses = decodeWireMessages(control.sent).map(message => JSON.parse(message.data));
|
|
730
|
+
assert.equal(responses.find(message => message.requestId === HUB_EPOCH_TWO)?.status, 200);
|
|
731
|
+
|
|
732
|
+
signal.receive(JSON.stringify({
|
|
733
|
+
type: 'workspace-access-refreshed',
|
|
734
|
+
consoleId: CONSOLE_ID,
|
|
735
|
+
connectionId: CONNECTION_ONE,
|
|
736
|
+
hubEpoch: HUB_EPOCH,
|
|
737
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
738
|
+
workspaceKind: 'team',
|
|
739
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
740
|
+
memberRole: 'operator',
|
|
741
|
+
membershipRevision: 8,
|
|
742
|
+
accessExpiresAt: Date.now() + 60_000
|
|
743
|
+
}), false);
|
|
744
|
+
assert.equal(direct.inspect().peerConnections, 1, 'A successful Team reauth must preserve the healthy P2P peer.');
|
|
745
|
+
assert.equal(direct.revokeWorkspaceMember(TEAM_WORKSPACE_ID, TEAM_OPERATOR_USER_ID), 1);
|
|
746
|
+
assert.equal(peer.closed, true);
|
|
747
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
748
|
+
assert.equal(direct.inspect().controlChannels, 0);
|
|
749
|
+
const peerCountBeforeReoffer = FakePeerConnection.instances.length;
|
|
750
|
+
offer(signal, CONNECTION_TWO, 'v=0\r\na=fake-offer\r\n', {
|
|
751
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
752
|
+
workspaceKind: 'team',
|
|
753
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
754
|
+
memberRole: 'operator',
|
|
755
|
+
membershipRevision: 8,
|
|
756
|
+
accessExpiresAt: Date.now() + 60_000
|
|
757
|
+
});
|
|
758
|
+
assert.equal(FakePeerConnection.instances.length, peerCountBeforeReoffer);
|
|
759
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
760
|
+
assert.equal(direct.inspect().revokedMemberFences, 1);
|
|
761
|
+
assert.equal(signalingMessages(signal).at(-1).reason, 'workspace-member-revoked');
|
|
762
|
+
direct.close();
|
|
763
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test('Team peer access refresh replaces the exact deadline and signaling loss cannot extend access', async () => {
|
|
767
|
+
const { direct, signal } = await connectedDirect({
|
|
768
|
+
retryDelaysMs: [60_000],
|
|
769
|
+
workspaceAccess: {
|
|
770
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
771
|
+
workspaceKind: 'team',
|
|
772
|
+
role: 'owner',
|
|
773
|
+
membershipRevision: 7
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
const peer = offer(signal, CONNECTION_ONE, 'v=0\r\na=fake-offer\r\n', {
|
|
777
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
778
|
+
workspaceKind: 'team',
|
|
779
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
780
|
+
memberRole: 'operator',
|
|
781
|
+
membershipRevision: 7,
|
|
782
|
+
accessExpiresAt: Date.now() + 30
|
|
783
|
+
});
|
|
784
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
785
|
+
peer.emitDataChannel(control);
|
|
786
|
+
control.open();
|
|
787
|
+
signal.receive(JSON.stringify({
|
|
788
|
+
type: 'workspace-access-refreshed',
|
|
789
|
+
consoleId: CONSOLE_ID,
|
|
790
|
+
connectionId: CONNECTION_ONE,
|
|
791
|
+
hubEpoch: HUB_EPOCH,
|
|
792
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
793
|
+
workspaceKind: 'team',
|
|
794
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
795
|
+
memberRole: 'operator',
|
|
796
|
+
membershipRevision: 8,
|
|
797
|
+
accessExpiresAt: Date.now() + 100
|
|
798
|
+
}), false);
|
|
799
|
+
signal.close(1012, 'signaling-temporarily-offline');
|
|
800
|
+
await new Promise(resolve => setTimeout(resolve, 45));
|
|
801
|
+
assert.equal(peer.closed, false, 'The stale pre-refresh deadline must not close a refreshed peer.');
|
|
802
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
803
|
+
await waitFor(() => peer.closed, 150);
|
|
804
|
+
assert.equal(direct.inspect().peerConnections, 0, 'A lost signaling socket must not extend Team access past its verified deadline.');
|
|
805
|
+
assert.equal(direct.inspect().localWebSocketChannels, 0);
|
|
806
|
+
direct.close();
|
|
807
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test('verified Personal Owner, Team Owner and Team Operator auth bootstrap discards every caller credential', async () => {
|
|
811
|
+
for (const scenario of [
|
|
812
|
+
{
|
|
813
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
814
|
+
workspaceKind: 'team',
|
|
815
|
+
userId: TEAM_OWNER_USER_ID,
|
|
816
|
+
role: 'owner',
|
|
817
|
+
accessExpiresAt: Date.now() + 60_000
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
821
|
+
workspaceKind: 'team',
|
|
822
|
+
userId: TEAM_OPERATOR_USER_ID,
|
|
823
|
+
role: 'operator',
|
|
824
|
+
accessExpiresAt: Date.now() + 60_000
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
workspaceId: TEAM_OWNER_USER_ID,
|
|
828
|
+
workspaceKind: 'personal',
|
|
829
|
+
userId: TEAM_OWNER_USER_ID,
|
|
830
|
+
role: 'owner',
|
|
831
|
+
accessExpiresAt: 0
|
|
832
|
+
}
|
|
833
|
+
]) {
|
|
834
|
+
const fetched = [];
|
|
835
|
+
const { direct, signal } = await connectedDirect({
|
|
836
|
+
workspaceAccess: {
|
|
837
|
+
workspaceId: scenario.workspaceId,
|
|
838
|
+
workspaceKind: scenario.workspaceKind,
|
|
839
|
+
role: 'owner',
|
|
840
|
+
membershipRevision: 3
|
|
841
|
+
},
|
|
842
|
+
consoleProxyToken: CONSOLE_PROXY_TOKEN,
|
|
843
|
+
fetchImpl: async (url, init) => {
|
|
844
|
+
fetched.push({ url: String(url), init });
|
|
845
|
+
return new Response('{"ok":true}', { headers: { 'Content-Type': 'application/json' } });
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
const peer = offer(signal, CONNECTION_ONE, 'v=0\r\na=fake-offer\r\n', {
|
|
849
|
+
workspaceId: scenario.workspaceId,
|
|
850
|
+
workspaceKind: scenario.workspaceKind,
|
|
851
|
+
memberUserId: scenario.userId,
|
|
852
|
+
memberRole: scenario.role,
|
|
853
|
+
membershipRevision: 3,
|
|
854
|
+
accessExpiresAt: scenario.accessExpiresAt
|
|
855
|
+
});
|
|
856
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
857
|
+
peer.emitDataChannel(control);
|
|
858
|
+
control.open();
|
|
859
|
+
sendWire(control, {
|
|
860
|
+
type: 'http-request',
|
|
861
|
+
requestId: REQUEST_ID,
|
|
862
|
+
method: 'POST',
|
|
863
|
+
path: '/api/auth/session',
|
|
864
|
+
headers: {
|
|
865
|
+
accept: 'application/attacker-controlled',
|
|
866
|
+
'content-type': 'text/plain',
|
|
867
|
+
'if-match': 'attacker-etag',
|
|
868
|
+
range: 'bytes=0-999',
|
|
869
|
+
'x-livedesk-csrf': 'attacker-csrf'
|
|
870
|
+
},
|
|
871
|
+
bodyBase64: Buffer.from(JSON.stringify({
|
|
872
|
+
workspaceId: HUB_EPOCH_TWO,
|
|
873
|
+
accessToken: 'attacker-access-token',
|
|
874
|
+
refreshToken: 'attacker-refresh-token',
|
|
875
|
+
user: { id: HUB_EPOCH_TWO },
|
|
876
|
+
userId: HUB_EPOCH_TWO,
|
|
877
|
+
expiresAt: 9_999_999_999
|
|
878
|
+
})).toString('base64')
|
|
879
|
+
});
|
|
880
|
+
await settle();
|
|
881
|
+
assert.equal(fetched.length, 1);
|
|
882
|
+
assert.equal(fetched[0].init.headers.Authorization, undefined);
|
|
883
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-Role'], scenario.role);
|
|
884
|
+
assert.equal(fetched[0].init.headers['X-LiveDesk-Workspace-Kind'], scenario.workspaceKind);
|
|
885
|
+
assert.equal(fetched[0].init.headers['content-type'], 'application/json');
|
|
886
|
+
assert.equal(fetched[0].init.headers.accept, undefined);
|
|
887
|
+
assert.equal(fetched[0].init.headers['if-match'], undefined);
|
|
888
|
+
assert.equal(fetched[0].init.headers.range, undefined);
|
|
889
|
+
assert.equal(fetched[0].init.headers['x-livedesk-csrf'], undefined);
|
|
890
|
+
assert.deepEqual(JSON.parse(Buffer.from(fetched[0].init.body).toString('utf8')), {
|
|
891
|
+
workspaceId: scenario.workspaceId
|
|
892
|
+
});
|
|
893
|
+
direct.close();
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test('cross-workspace Worker claims are rejected before allocating a P2P peer', async () => {
|
|
898
|
+
const { direct, signal } = await connectedDirect({
|
|
899
|
+
workspaceAccess: {
|
|
900
|
+
workspaceId: TEAM_WORKSPACE_ID,
|
|
901
|
+
workspaceKind: 'team',
|
|
902
|
+
role: 'owner',
|
|
903
|
+
membershipRevision: 1
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
offer(signal, CONNECTION_ONE, 'v=0\r\na=fake-offer\r\n', {
|
|
907
|
+
workspaceId: HUB_EPOCH_TWO,
|
|
908
|
+
workspaceKind: 'team',
|
|
909
|
+
memberUserId: TEAM_OPERATOR_USER_ID,
|
|
910
|
+
memberRole: 'operator',
|
|
911
|
+
membershipRevision: 1,
|
|
912
|
+
accessExpiresAt: Date.now() + 60_000
|
|
913
|
+
});
|
|
914
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
915
|
+
assert.equal(signalingMessages(signal).some(message => (
|
|
916
|
+
message.type === 'rtc-close'
|
|
917
|
+
&& message.reason === 'console-workspace-access-invalid'
|
|
918
|
+
)), true);
|
|
919
|
+
direct.close();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test('Operator request policy is limited to Wall, Control, audio and self sign-out', () => {
|
|
923
|
+
assert.equal(workspaceRoleCanRequest('operator', 'GET', '/api/hub/devices'), true);
|
|
924
|
+
assert.equal(workspaceRoleCanRequest('operator', 'GET', '/api/remote/devices/client-1/thumbnail'), true);
|
|
925
|
+
assert.equal(workspaceRoleCanRequest('operator', 'POST', '/api/remote/devices/client-1/thumbnail/request'), true);
|
|
926
|
+
assert.equal(workspaceRoleCanRequest('operator', 'GET', '/api/remote/devices/client-1/live/frame'), true);
|
|
927
|
+
assert.equal(workspaceRoleCanRequest('operator', 'POST', '/api/remote/devices/client-1/live/start'), true);
|
|
928
|
+
assert.equal(workspaceRoleCanRequest('operator', 'POST', '/api/remote/devices/client-1/input'), true);
|
|
929
|
+
assert.equal(workspaceRoleCanRequest('operator', 'POST', '/api/remote/devices/client-1/audio/start'), true);
|
|
930
|
+
assert.equal(workspaceRoleCanRequest('operator', 'DELETE', '/api/auth/session'), true);
|
|
931
|
+
for (const [method, path] of [
|
|
932
|
+
['POST', '/api/runtime/restart'],
|
|
933
|
+
['POST', '/api/runtime/shutdown'],
|
|
934
|
+
['POST', '/api/runtime/role'],
|
|
935
|
+
['POST', '/api/hub/sync'],
|
|
936
|
+
['POST', '/api/update/apply'],
|
|
937
|
+
['PATCH', '/api/settings/profile'],
|
|
938
|
+
['POST', '/api/files/upload'],
|
|
939
|
+
['POST', '/api/tasks'],
|
|
940
|
+
['POST', `/api/remote/workspace/members/${TEAM_OWNER_USER_ID}/revoke`]
|
|
941
|
+
]) {
|
|
942
|
+
assert.equal(workspaceRoleCanRequest('operator', method, path), false, `${method} ${path}`);
|
|
943
|
+
}
|
|
944
|
+
assert.equal(workspaceRoleCanRequest('owner', 'POST', '/api/update/apply'), true);
|
|
945
|
+
});
|
|
946
|
+
|
|
618
947
|
test('canonical HTTP routing rejects encoded, backslash, duplicate-slash, and external-origin bypasses', async () => {
|
|
619
948
|
const fetchedUrls = [];
|
|
620
949
|
const { direct, signal } = await connectedDirect({
|
|
@@ -34,33 +34,97 @@ function randomToken() {
|
|
|
34
34
|
return crypto.randomBytes(32).toString('base64url');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function createHubUiSessionAuthority({
|
|
38
|
-
ttlMs = HUB_UI_SESSION_TTL_MS,
|
|
39
|
-
maxSessions = 32,
|
|
40
|
-
now = () => Date.now()
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
export function createHubUiSessionAuthority({
|
|
38
|
+
ttlMs = HUB_UI_SESSION_TTL_MS,
|
|
39
|
+
maxSessions = 32,
|
|
40
|
+
now = () => Date.now(),
|
|
41
|
+
onRevoke = () => {}
|
|
42
|
+
} = {}) {
|
|
43
|
+
const sessions = new Map();
|
|
44
|
+
|
|
45
|
+
function dropSession(sessionId, reason) {
|
|
46
|
+
const session = sessions.get(sessionId);
|
|
47
|
+
if (!session) return null;
|
|
48
|
+
sessions.delete(sessionId);
|
|
49
|
+
try {
|
|
50
|
+
onRevoke({ sessionId, ...session }, String(reason || 'hub-ui-session-revoked'));
|
|
51
|
+
} catch {
|
|
52
|
+
// Revocation remains authoritative even if a best-effort socket cleanup
|
|
53
|
+
// listener fails. The next protected request still fails closed.
|
|
54
|
+
}
|
|
55
|
+
return session;
|
|
56
|
+
}
|
|
43
57
|
|
|
44
58
|
function purgeExpired() {
|
|
45
|
-
const current = now();
|
|
46
|
-
for (const [sessionId, session] of sessions) {
|
|
47
|
-
if (session.expiresAt <= current
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
const current = now();
|
|
60
|
+
for (const [sessionId, session] of sessions) {
|
|
61
|
+
if (session.expiresAt <= current
|
|
62
|
+
|| (session.accessExpiresAt > 0 && session.accessExpiresAt <= current)) {
|
|
63
|
+
dropSession(sessionId, 'hub-ui-session-expired');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
while (sessions.size >= maxSessions) {
|
|
67
|
+
dropSession(sessions.keys().next().value, 'hub-ui-session-capacity-evicted');
|
|
51
68
|
}
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
function
|
|
55
|
-
const normalizedUserId = String(userId || '').trim();
|
|
56
|
-
if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
function normalizedSessionAccess(userId, access = {}) {
|
|
72
|
+
const normalizedUserId = String(userId || '').trim();
|
|
73
|
+
if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
|
|
74
|
+
const workspaceId = String(access.workspaceId || '').trim();
|
|
75
|
+
const workspaceKind = String(access.workspaceKind || 'personal').trim().toLowerCase();
|
|
76
|
+
const role = String(access.role || 'owner').trim().toLowerCase();
|
|
77
|
+
const entitlementStatus = String(access.entitlementStatus || 'active').trim().toLowerCase();
|
|
78
|
+
const membershipRevision = Math.max(0, Number(access.membershipRevision || 0));
|
|
79
|
+
const accessExpiresAt = Math.max(0, Number(access.accessExpiresAt || 0));
|
|
80
|
+
if (workspaceId && !['owner', 'operator'].includes(role)) {
|
|
81
|
+
throw new Error('hub-ui-session-workspace-role-invalid');
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
userId: normalizedUserId,
|
|
85
|
+
workspaceId,
|
|
86
|
+
workspaceKind,
|
|
87
|
+
role,
|
|
88
|
+
plan: String(access.plan || 'free').trim().toLowerCase(),
|
|
89
|
+
entitlementStatus,
|
|
90
|
+
membershipRevision,
|
|
91
|
+
accessExpiresAt
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function issue(userId, access = {}) {
|
|
96
|
+
const normalized = normalizedSessionAccess(userId, access);
|
|
97
|
+
purgeExpired();
|
|
98
|
+
const sessionId = randomToken();
|
|
99
|
+
const csrfToken = randomToken();
|
|
100
|
+
const expiresAt = now() + ttlMs;
|
|
101
|
+
sessions.set(sessionId, {
|
|
102
|
+
...normalized,
|
|
103
|
+
csrfToken,
|
|
104
|
+
expiresAt
|
|
105
|
+
});
|
|
106
|
+
return { sessionId, csrfToken, expiresAt };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renew(req, userId, access = {}) {
|
|
110
|
+
purgeExpired();
|
|
111
|
+
const sessionId = parseCookies(req?.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
|
|
112
|
+
const session = sessions.get(sessionId);
|
|
113
|
+
const normalized = normalizedSessionAccess(userId, access);
|
|
114
|
+
if (!session
|
|
115
|
+
|| session.userId !== normalized.userId
|
|
116
|
+
|| session.workspaceId !== normalized.workspaceId
|
|
117
|
+
|| session.role !== normalized.role) {
|
|
118
|
+
return issue(userId, access);
|
|
119
|
+
}
|
|
120
|
+
const expiresAt = now() + ttlMs;
|
|
121
|
+
sessions.set(sessionId, {
|
|
122
|
+
...session,
|
|
123
|
+
...normalized,
|
|
124
|
+
expiresAt
|
|
125
|
+
});
|
|
126
|
+
return { sessionId, csrfToken: session.csrfToken, expiresAt, renewed: true };
|
|
127
|
+
}
|
|
64
128
|
|
|
65
129
|
function authorize(req, currentUserId, {
|
|
66
130
|
requireCsrf = !SAFE_HTTP_METHODS.has(String(req.method || 'GET').toUpperCase())
|
|
@@ -74,23 +138,82 @@ export function createHubUiSessionAuthority({
|
|
|
74
138
|
if (!session) {
|
|
75
139
|
return { ok: false, status: 401, error: 'hub-ui-session-expired' };
|
|
76
140
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
141
|
+
const expected = currentUserId && typeof currentUserId === 'object'
|
|
142
|
+
? currentUserId
|
|
143
|
+
: { userId: currentUserId };
|
|
144
|
+
const normalizedUserId = String(expected.userId || '').trim();
|
|
145
|
+
const expectedWorkspaceId = String(expected.workspaceId || '').trim();
|
|
146
|
+
const allowedRoles = new Set(Array.isArray(expected.allowedRoles)
|
|
147
|
+
? expected.allowedRoles.map(role => String(role || '').trim().toLowerCase())
|
|
148
|
+
: ['owner', 'operator']);
|
|
149
|
+
const identityMismatch = expectedWorkspaceId
|
|
150
|
+
? session.workspaceId !== expectedWorkspaceId || !allowedRoles.has(session.role)
|
|
151
|
+
: !normalizedUserId || session.userId !== normalizedUserId;
|
|
152
|
+
if (identityMismatch || (session.accessExpiresAt > 0 && session.accessExpiresAt <= now())) {
|
|
153
|
+
dropSession(sessionId, 'hub-ui-session-account-mismatch');
|
|
154
|
+
return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
|
|
81
155
|
}
|
|
82
156
|
if (requireCsrf && !secureEqual(req.headers?.['x-livedesk-csrf'], session.csrfToken)) {
|
|
83
157
|
return { ok: false, status: 403, error: 'hub-ui-csrf-invalid' };
|
|
84
158
|
}
|
|
85
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
sessionId,
|
|
162
|
+
userId: session.userId,
|
|
163
|
+
workspaceId: session.workspaceId,
|
|
164
|
+
workspaceKind: session.workspaceKind,
|
|
165
|
+
role: session.role,
|
|
166
|
+
plan: session.plan,
|
|
167
|
+
entitlementStatus: session.entitlementStatus,
|
|
168
|
+
membershipRevision: session.membershipRevision,
|
|
169
|
+
accessExpiresAt: session.accessExpiresAt,
|
|
170
|
+
expiresAt: session.expiresAt
|
|
171
|
+
};
|
|
86
172
|
}
|
|
87
173
|
|
|
88
174
|
return {
|
|
89
|
-
issue,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
175
|
+
issue,
|
|
176
|
+
renew,
|
|
177
|
+
authorize,
|
|
178
|
+
sessionDeadline(sessionId) {
|
|
179
|
+
purgeExpired();
|
|
180
|
+
const session = sessions.get(String(sessionId || ''));
|
|
181
|
+
if (!session) return 0;
|
|
182
|
+
return session.accessExpiresAt > 0
|
|
183
|
+
? Math.min(session.expiresAt, session.accessExpiresAt)
|
|
184
|
+
: session.expiresAt;
|
|
185
|
+
},
|
|
186
|
+
revokeAll(reason = 'hub-ui-session-revoked-all') {
|
|
187
|
+
let revoked = 0;
|
|
188
|
+
for (const sessionId of [...sessions.keys()]) {
|
|
189
|
+
if (dropSession(sessionId, reason)) revoked += 1;
|
|
190
|
+
}
|
|
191
|
+
return revoked;
|
|
192
|
+
},
|
|
193
|
+
revokeWorkspaceMember(workspaceId, userId) {
|
|
194
|
+
const normalizedWorkspaceId = String(workspaceId || '').trim();
|
|
195
|
+
const normalizedUserId = String(userId || '').trim();
|
|
196
|
+
let revoked = 0;
|
|
197
|
+
for (const [sessionId, session] of sessions) {
|
|
198
|
+
if (session.workspaceId !== normalizedWorkspaceId || session.userId !== normalizedUserId) continue;
|
|
199
|
+
if (dropSession(sessionId, 'workspace-member-revoked')) revoked += 1;
|
|
200
|
+
}
|
|
201
|
+
return revoked;
|
|
202
|
+
},
|
|
203
|
+
revoke(req) {
|
|
204
|
+
const sessionId = parseCookies(req?.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
|
|
205
|
+
if (!sessionId) return null;
|
|
206
|
+
const session = dropSession(sessionId, 'hub-ui-session-signed-out');
|
|
207
|
+
return session ? {
|
|
208
|
+
sessionId,
|
|
209
|
+
userId: session.userId,
|
|
210
|
+
workspaceId: session.workspaceId,
|
|
211
|
+
workspaceKind: session.workspaceKind,
|
|
212
|
+
role: session.role,
|
|
213
|
+
plan: session.plan,
|
|
214
|
+
entitlementStatus: session.entitlementStatus
|
|
215
|
+
} : null;
|
|
216
|
+
},
|
|
94
217
|
sessionCount: () => sessions.size
|
|
95
218
|
};
|
|
96
219
|
}
|