security: bind TURN grants to screen sessions
This commit is contained in:
@@ -124,6 +124,28 @@ function grant(
|
||||
};
|
||||
}
|
||||
|
||||
function iceGrant(requestId: string) {
|
||||
return {
|
||||
type: "ice-config-grant",
|
||||
protocolVersion: 1,
|
||||
requestId,
|
||||
issuedAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 50_000,
|
||||
iceServers: [{
|
||||
urls: ["turns:relay.example.test:5349"],
|
||||
username: "temporary-user-01",
|
||||
credential: "temporary-password-01",
|
||||
credentialType: "password",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function iceResponse(input: RequestInfo | URL, body: Record<string, unknown>): Response | null {
|
||||
return String(input).endsWith("/ice")
|
||||
? Response.json(iceGrant(String(body.requestId)))
|
||||
: null;
|
||||
}
|
||||
|
||||
function sse(messages: readonly unknown[]): Response {
|
||||
const bytes = new TextEncoder().encode(messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join(""));
|
||||
return new Response(new ReadableStream<Uint8Array>({
|
||||
@@ -165,6 +187,8 @@ describe("remote office media presenter", () => {
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
calls.push({ url: String(input), init, body });
|
||||
const ice = iceResponse(input, body);
|
||||
if (ice) return ice;
|
||||
if (String(input).endsWith("/join")) {
|
||||
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
|
||||
}
|
||||
@@ -189,6 +213,16 @@ describe("remote office media presenter", () => {
|
||||
assert.equal(video.autoplay, false);
|
||||
assert.equal(video.muted, true);
|
||||
const signal = calls.find((call) => call.url.endsWith("/signal"));
|
||||
const joinIndex = calls.findIndex((call) => call.url.endsWith("/join"));
|
||||
const iceIndex = calls.findIndex((call) => call.url.endsWith("/ice"));
|
||||
assert.ok(joinIndex >= 0 && iceIndex > joinIndex, "signaling authorization precedes TURN exchange");
|
||||
assert.deepEqual(calls[iceIndex]?.body, {
|
||||
type: "ice-config-request",
|
||||
protocolVersion: 1,
|
||||
requestId: calls[iceIndex]?.body.requestId,
|
||||
binding: BINDING,
|
||||
credential: access("presenter", "presenter-opaque").credential,
|
||||
});
|
||||
assert.equal((signal?.body.signal as { kind?: string }).kind, "sdp");
|
||||
assert.equal(signal?.init.credentials, "same-origin");
|
||||
assert.equal(calls.some((call) => call.url.includes("private-presenter-grant")), false);
|
||||
@@ -201,11 +235,23 @@ describe("remote office media presenter", () => {
|
||||
it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
|
||||
const timers = new FakeTimers();
|
||||
const peers: FakePeer[] = [];
|
||||
const iceTokens: string[] = [];
|
||||
let eventCalls = 0;
|
||||
const participant = { participantId: "viewer-opaque", role: "viewer" as const };
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
const ice = iceResponse(input, body);
|
||||
if (ice) {
|
||||
iceTokens.push((body.credential as { grantToken: string }).grantToken);
|
||||
return ice;
|
||||
}
|
||||
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [participant]), { status: 201 });
|
||||
if (String(input).endsWith("/events")) return sse([grant("screen-share-resume-grant", String(body.requestId), "presenter", [participant])]);
|
||||
if (String(input).endsWith("/events")) {
|
||||
eventCalls += 1;
|
||||
const resumed = grant("screen-share-resume-grant", String(body.requestId), "presenter", [participant]);
|
||||
resumed.grant.credential.grantToken = `rotated-reconnect-${eventCalls}`;
|
||||
return sse([resumed]);
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const media = createRemoteOfficeMedia({
|
||||
@@ -214,6 +260,8 @@ describe("remote office media presenter", () => {
|
||||
});
|
||||
const track = new FakeTrack();
|
||||
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: new FakeStream([track]) as unknown as MediaStream, video: new FakeVideo() as unknown as HTMLVideoElement });
|
||||
await settle();
|
||||
assert.deepEqual(iceTokens, ["private-presenter-grant"], "connected peer keeps its active TURN allocation across token rotation");
|
||||
peers[0]?.fail();
|
||||
assert.equal(media.state().status, "reconnecting");
|
||||
assert.equal(peers[0]?.closed, 1);
|
||||
@@ -221,6 +269,8 @@ describe("remote office media presenter", () => {
|
||||
await settle();
|
||||
assert.equal(peers.length, 2);
|
||||
assert.equal(peers[1]?.added[0], track as unknown as MediaStreamTrack);
|
||||
assert.deepEqual(iceTokens, ["private-presenter-grant", "rotated-reconnect-2"],
|
||||
"rebuilt peer refreshes TURN under the current rotated grant");
|
||||
await media.dispose();
|
||||
assert.equal(track.stopped, 0);
|
||||
});
|
||||
@@ -230,6 +280,8 @@ describe("remote office media presenter", () => {
|
||||
let joinRequestId = "";
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
const ice = iceResponse(input, body);
|
||||
if (ice) return ice;
|
||||
if (String(input).endsWith("/join")) {
|
||||
joinRequestId = String(body.requestId);
|
||||
return new Promise<Response>((resolve) => { resolveJoin = resolve; });
|
||||
@@ -257,9 +309,15 @@ describe("remote office media presenter", () => {
|
||||
it("renews its lease before expiry and uses only the rotated in-memory grant", async () => {
|
||||
const timers = new FakeTimers();
|
||||
const eventTokens: string[] = [];
|
||||
const iceTokens: string[] = [];
|
||||
let eventCalls = 0;
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
const ice = iceResponse(input, body);
|
||||
if (ice) {
|
||||
iceTokens.push((body.credential as { grantToken: string }).grantToken);
|
||||
return ice;
|
||||
}
|
||||
if (String(input).endsWith("/join")) {
|
||||
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
|
||||
}
|
||||
@@ -284,13 +342,49 @@ describe("remote office media presenter", () => {
|
||||
});
|
||||
await settle();
|
||||
assert.deepEqual(eventTokens, ["private-presenter-grant"]);
|
||||
assert.deepEqual(iceTokens, ["private-presenter-grant"], "initial resume reuses active TURN configuration");
|
||||
assert.equal(timers.pending.size, 1, "one pre-expiry renewal is scheduled");
|
||||
timers.run();
|
||||
await settle();
|
||||
assert.deepEqual(eventTokens, ["private-presenter-grant", "rotated-1"]);
|
||||
assert.deepEqual(iceTokens, ["private-presenter-grant"], "lease renewal does not issue unused TURN credentials");
|
||||
assert.equal(JSON.stringify(media.state()).includes("rotated-2"), false);
|
||||
await media.dispose();
|
||||
});
|
||||
|
||||
it("creates no peer before grant-bound ICE succeeds and cleans up a failed hosted join", async () => {
|
||||
let peerCount = 0;
|
||||
const urls: string[] = [];
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
if (url.endsWith("/join")) {
|
||||
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [
|
||||
{ participantId: "viewer-opaque", role: "viewer" },
|
||||
]), { status: 201 });
|
||||
}
|
||||
if (url.endsWith("/ice")) return Response.json({
|
||||
type: "ice-config-unavailable", protocolVersion: 1,
|
||||
requestId: body.requestId, retryAfterMs: 10_000,
|
||||
}, { status: 503 });
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const track = new FakeTrack();
|
||||
const media = createRemoteOfficeMedia({
|
||||
role: "presenter", binding: BINDING, fetch: fetcher, now: () => NOW,
|
||||
peerConnectionFactory: () => { peerCount += 1; return new FakePeer() as unknown as RTCPeerConnection; },
|
||||
});
|
||||
await assert.rejects(media.startPresenter({
|
||||
consent: { authorized: true, optedIn: true },
|
||||
stream: new FakeStream([track]) as unknown as MediaStream,
|
||||
video: new FakeVideo() as unknown as HTMLVideoElement,
|
||||
}), /temporarily unavailable/);
|
||||
assert.equal(peerCount, 0);
|
||||
assert.deepEqual(urls.map((url) => url.split("/").at(-1)), ["join", "ice", "leave"]);
|
||||
assert.equal(track.stopped, 0);
|
||||
assert.equal(media.state().status, "idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote office media viewer", () => {
|
||||
@@ -301,6 +395,8 @@ describe("remote office media viewer", () => {
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
calls.push({ url: String(input), body });
|
||||
const iceConfig = iceResponse(input, body);
|
||||
if (iceConfig) return iceConfig;
|
||||
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
|
||||
if (String(input).endsWith("/events")) {
|
||||
const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]);
|
||||
@@ -348,6 +444,8 @@ describe("remote office media viewer", () => {
|
||||
let leaveCalls = 0;
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
const ice = iceResponse(input, body);
|
||||
if (ice) return ice;
|
||||
if (String(input).endsWith("/join")) {
|
||||
return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user