security: bind TURN grants to screen sessions
This commit is contained in:
+2
-16
@@ -2365,17 +2365,10 @@ async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void>
|
||||
remotePanelStatus(surface.screenId, "connecting");
|
||||
showDetail(`Connecting to ${surface.screenId}…`);
|
||||
try {
|
||||
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([
|
||||
import("./media/remoteMedia.ts"),
|
||||
import("./media/iceClient.ts"),
|
||||
]);
|
||||
const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
|
||||
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
|
||||
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
|
||||
office.depth !== "full" || officeId !== surface.officeId || !optedIn) return;
|
||||
const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch });
|
||||
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
|
||||
office.depth !== "full" || officeId !== surface.officeId ||
|
||||
!(officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false)) return;
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
@@ -2388,7 +2381,6 @@ async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void>
|
||||
role: "viewer",
|
||||
binding: screenBinding(surface),
|
||||
authenticatedFetch: authFetch,
|
||||
peerConnectionConfiguration: ice.configuration,
|
||||
onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); },
|
||||
onError: (error) => {
|
||||
if (remoteViewedScreen !== viewing) return;
|
||||
@@ -2423,18 +2415,12 @@ async function startRemotePresenter(
|
||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||||
remotePanelStatus(surface.screenId, "connecting");
|
||||
try {
|
||||
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([
|
||||
import("./media/remoteMedia.ts"),
|
||||
import("./media/iceClient.ts"),
|
||||
]);
|
||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||||
const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch });
|
||||
const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
|
||||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||||
const remote = createRemoteOfficeMedia({
|
||||
role: "presenter",
|
||||
binding: screenBinding(surface),
|
||||
authenticatedFetch: authFetch,
|
||||
peerConnectionConfiguration: ice.configuration,
|
||||
onStateChange(state) {
|
||||
if (sharedScreen !== shared) return;
|
||||
if (state.status === "live") remotePanelStatus(surface.screenId, "live");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts";
|
||||
import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts";
|
||||
import type { ScreenShareBinding, ScreenShareCredential } from "./signalingTypes.ts";
|
||||
|
||||
export interface EphemeralIceConfiguration {
|
||||
readonly configuration: RTCConfiguration;
|
||||
@@ -8,6 +9,9 @@ export interface EphemeralIceConfiguration {
|
||||
|
||||
export interface FetchEphemeralIceConfigurationOptions {
|
||||
authenticatedFetch: typeof globalThis.fetch;
|
||||
/** Exact server-authored screen and active signaling capability. */
|
||||
binding: ScreenShareBinding;
|
||||
credential: ScreenShareCredential;
|
||||
endpoint?: string;
|
||||
now?: () => number;
|
||||
requestId?: () => string;
|
||||
@@ -49,6 +53,8 @@ export async function fetchEphemeralIceConfiguration(
|
||||
type: "ice-config-request",
|
||||
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
|
||||
requestId: id,
|
||||
binding: { ...options.binding },
|
||||
credential: { ...options.credential },
|
||||
}),
|
||||
});
|
||||
let body: unknown;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/** JSON-only contract for fetching ephemeral WebRTC ICE configuration. */
|
||||
|
||||
import type { ScreenShareBinding, ScreenShareCredential } from "./signalingTypes.ts";
|
||||
|
||||
export const ICE_CONFIG_PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
export interface IceConfigRequest {
|
||||
type: "ice-config-request";
|
||||
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
|
||||
requestId: string;
|
||||
binding: ScreenShareBinding;
|
||||
credential: ScreenShareCredential;
|
||||
}
|
||||
|
||||
export interface StunIceServer {
|
||||
|
||||
@@ -35,12 +35,27 @@ export function isSafeIceUrl(value: unknown): value is string {
|
||||
}
|
||||
|
||||
export function parseIceConfigRequest(value: unknown): IceConfigValidationResult<IceConfigRequest> {
|
||||
if (!exact(value, ["type", "protocolVersion", "requestId"]) ||
|
||||
if (!exact(value, ["type", "protocolVersion", "requestId", "binding", "credential"]) ||
|
||||
value.type !== "ice-config-request" || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION ||
|
||||
!identifier(value.requestId)) return failure("invalid request");
|
||||
!identifier(value.requestId) || !screenBinding(value.binding) ||
|
||||
!screenCredential(value.credential)) return failure("invalid request");
|
||||
return success(value as unknown as IceConfigRequest);
|
||||
}
|
||||
|
||||
function screenBinding(value: unknown): boolean {
|
||||
return exact(value, ["officeId", "levelId", "roomId", "screenId"]) &&
|
||||
identifier(value.officeId) && identifier(value.levelId) &&
|
||||
(value.roomId === null || identifier(value.roomId)) && identifier(value.screenId);
|
||||
}
|
||||
|
||||
function screenCredential(value: unknown): boolean {
|
||||
return exact(value, ["sessionId", "participantId", "role", "grantToken"]) &&
|
||||
identifier(value.sessionId) && identifier(value.participantId) &&
|
||||
(value.role === "presenter" || value.role === "viewer") &&
|
||||
typeof value.grantToken === "string" && value.grantToken.length >= 16 &&
|
||||
value.grantToken.length <= 512;
|
||||
}
|
||||
|
||||
export function parseIceConfigResponse(value: unknown): IceConfigValidationResult<IceConfigResponse> {
|
||||
if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) {
|
||||
return failure("invalid response envelope");
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
type ScreenShareStreamCursor,
|
||||
} from "./signalingTypes.ts";
|
||||
import type { MediaAuthorizationDecision } from "./types.ts";
|
||||
import {
|
||||
fetchEphemeralIceConfiguration,
|
||||
type EphemeralIceConfiguration,
|
||||
} from "./iceClient.ts";
|
||||
|
||||
export type RemoteMediaRole = ScreenShareRole;
|
||||
export type RemoteMediaStatus = "idle" | "connecting" | "live" | "reconnecting" | "stopped" | "revoked" | "disposed";
|
||||
@@ -33,7 +37,7 @@ export interface RemoteMediaState {
|
||||
reconnectAttempt: number;
|
||||
hasRemoteVideo: boolean;
|
||||
}
|
||||
export interface RemoteMediaEndpoints { join: string; signal: string; events: string; leave: string }
|
||||
export interface RemoteMediaEndpoints { join: string; signal: string; events: string; leave: string; ice: string }
|
||||
export interface RemoteMediaTimers {
|
||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||
@@ -45,7 +49,11 @@ export interface RemoteOfficeMediaOptions {
|
||||
authenticatedFetch?: typeof globalThis.fetch;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
peerConnectionFactory?: (configuration?: RTCConfiguration) => RTCPeerConnection;
|
||||
peerConnectionConfiguration?: RTCConfiguration;
|
||||
/** Injectable for tests/deployments; called only with an active in-memory grant. */
|
||||
iceConfigurationProvider?(authorization: {
|
||||
binding: ScreenShareBinding;
|
||||
credential: ScreenShareCredential;
|
||||
}): Promise<EphemeralIceConfiguration>;
|
||||
endpoints?: Partial<RemoteMediaEndpoints>;
|
||||
timers?: RemoteMediaTimers;
|
||||
now?: () => number;
|
||||
@@ -95,6 +103,7 @@ const ENDPOINTS: RemoteMediaEndpoints = {
|
||||
signal: "/api/v1/media/signal",
|
||||
events: "/api/v1/media/events",
|
||||
leave: "/api/v1/media/leave",
|
||||
ice: "/api/v1/media/ice",
|
||||
};
|
||||
const TIMERS: RemoteMediaTimers = {
|
||||
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
|
||||
@@ -144,6 +153,14 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
const now = options.now ?? Date.now;
|
||||
const reconnectBase = options.reconnectBaseMs ?? 500;
|
||||
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000;
|
||||
const provideIce = options.iceConfigurationProvider ?? ((authorization) =>
|
||||
fetchEphemeralIceConfiguration({
|
||||
authenticatedFetch: fetcher,
|
||||
endpoint: endpoints.ice,
|
||||
now,
|
||||
binding: authorization.binding,
|
||||
credential: authorization.credential,
|
||||
}));
|
||||
if (!(reconnectBase > 0) || !Number.isFinite(reconnectBase) || reconnectMaximum < reconnectBase) {
|
||||
throw new RangeError("remote media: invalid reconnect bounds");
|
||||
}
|
||||
@@ -157,6 +174,8 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
let presenter: PresenterStart | null = null;
|
||||
let viewer: ViewerStart | null = null;
|
||||
let receiverStream: MediaStream | null = null;
|
||||
let iceConfiguration: EphemeralIceConfiguration | null = null;
|
||||
let iceAuthorization: ScreenShareCredential | null = null;
|
||||
let eventAbort: AbortController | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let renewalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -225,6 +244,8 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
renewalTimer = null;
|
||||
for (const id of [...peers.keys()]) closePeer(id);
|
||||
releaseReceiver();
|
||||
iceConfiguration = null;
|
||||
iceAuthorization = null;
|
||||
}
|
||||
|
||||
function resetPeerMesh(): void {
|
||||
@@ -232,6 +253,33 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
releaseReceiver();
|
||||
}
|
||||
|
||||
async function refreshIceConfiguration(expectedGrant: ScreenShareAccessGrant): Promise<void> {
|
||||
const activeGeneration = generation;
|
||||
const next = await provideIce({ binding: { ...binding }, credential: { ...expectedGrant.credential } });
|
||||
if (generation !== activeGeneration || grant !== expectedGrant || !isScreenShareGrantActive(expectedGrant, now())) {
|
||||
throw new Error("remote media: ICE configuration was superseded");
|
||||
}
|
||||
if (!Number.isSafeInteger(next.expiresAtMs) || next.expiresAtMs <= now() || !next.configuration.iceServers?.length) {
|
||||
throw new Error("remote media: ICE configuration is not active");
|
||||
}
|
||||
iceConfiguration = {
|
||||
configuration: { ...next.configuration, iceServers: next.configuration.iceServers.map((server) => ({ ...server })) },
|
||||
expiresAtMs: next.expiresAtMs,
|
||||
};
|
||||
iceAuthorization = { ...expectedGrant.credential };
|
||||
}
|
||||
|
||||
async function ensureIceConfiguration(): Promise<void> {
|
||||
if (!grant) throw new Error("remote media: signaling grant is missing");
|
||||
const credential = grant.credential;
|
||||
if (iceConfiguration && iceConfiguration.expiresAtMs > now() && iceAuthorization &&
|
||||
iceAuthorization.sessionId === credential.sessionId &&
|
||||
iceAuthorization.participantId === credential.participantId &&
|
||||
iceAuthorization.role === credential.role &&
|
||||
iceAuthorization.grantToken === credential.grantToken) return;
|
||||
await refreshIceConfiguration(grant);
|
||||
}
|
||||
|
||||
async function sendSignal(targetParticipantId: string, signal: ScreenShareSignalPayload): Promise<void> {
|
||||
if (!grant) throw new Error("remote media: signaling grant is missing");
|
||||
const request: ScreenShareClientMessage = {
|
||||
@@ -250,7 +298,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
const prior = peers.get(participant.participantId);
|
||||
if (prior) return prior;
|
||||
const activeGeneration = generation;
|
||||
const connection = makePeer(options.peerConnectionConfiguration);
|
||||
if (!iceConfiguration || iceConfiguration.expiresAtMs <= now()) {
|
||||
throw new Error("remote media: active ICE configuration is required before creating a peer");
|
||||
}
|
||||
const connection = makePeer(iceConfiguration.configuration);
|
||||
const slot: PeerSlot = { id: participant.participantId, connection, pendingIce: [] };
|
||||
peers.set(slot.id, slot);
|
||||
connection.onicecandidate = (event) => {
|
||||
@@ -305,6 +356,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
const ids = new Set(allowed.map((item) => item.participantId));
|
||||
for (const id of [...peers.keys()]) if (!ids.has(id)) closePeer(id);
|
||||
participants = allowed;
|
||||
if (allowed.some((participant) => !peers.has(participant.participantId))) await ensureIceConfiguration();
|
||||
if (options.role === "presenter") {
|
||||
for (const participant of allowed) if (!peers.has(participant.participantId)) await offer(participant);
|
||||
} else {
|
||||
@@ -343,6 +395,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
if (message.targetParticipantId !== grant.credential.participantId) return;
|
||||
const from = participants.find((item) => item.participantId === message.fromParticipantId);
|
||||
if (!from) return;
|
||||
if (!peers.has(from.participantId)) await ensureIceConfiguration();
|
||||
const connection = configurePeer(from).connection;
|
||||
if (message.signal.kind === "ice-complete") {
|
||||
if (connection.remoteDescription) await connection.addIceCandidate(null);
|
||||
@@ -508,6 +561,9 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
grant = response.grant;
|
||||
clientSequence = response.nextClientSequence;
|
||||
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs };
|
||||
// Signaling authorization always precedes TURN. This is the first point at
|
||||
// which an ICE request can carry a server-issued credential and binding.
|
||||
await refreshIceConfiguration(response.grant);
|
||||
await reconcile(response.participants);
|
||||
scheduleRenewal();
|
||||
if (role === "presenter" && peers.size === 0) setStatus("live");
|
||||
@@ -532,8 +588,11 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
await join("presenter", generation);
|
||||
} catch (reason) {
|
||||
if (status === "connecting") {
|
||||
closeTransport();
|
||||
grant = null;
|
||||
// A join can succeed while the grant-bound TURN exchange fails. End
|
||||
// that just-created hosted session with its in-memory capability
|
||||
// instead of abandoning it until lease expiry. Caller capture remains
|
||||
// untouched and can continue as a local preview.
|
||||
try { await leave(); } catch { closeTransport(); grant = null; participants = []; }
|
||||
presenter = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
@@ -564,8 +623,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
||||
await join("viewer", generation);
|
||||
} catch (reason) {
|
||||
if (status === "connecting") {
|
||||
closeTransport();
|
||||
grant = null;
|
||||
try { await leave(); } catch { closeTransport(); grant = null; participants = []; }
|
||||
viewer = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ import {
|
||||
IceConfigurationUnavailableError,
|
||||
} from "../media/iceClient.ts";
|
||||
|
||||
const authorization = {
|
||||
binding: { officeId: "lumbridge-hq", levelId: "level-1", roomId: "commons", screenId: "wall-display" },
|
||||
credential: {
|
||||
sessionId: "share-session", participantId: "viewer-opaque", role: "viewer" as const,
|
||||
grantToken: "private-viewer-grant",
|
||||
},
|
||||
};
|
||||
|
||||
describe("ephemeral ICE configuration client", () => {
|
||||
it("posts the exact authenticated request and returns an in-memory RTC configuration", async () => {
|
||||
let input: RequestInfo | URL | undefined;
|
||||
@@ -32,6 +40,7 @@ describe("ephemeral ICE configuration client", () => {
|
||||
},
|
||||
now: () => 200,
|
||||
requestId: () => "ice-test-1",
|
||||
...authorization,
|
||||
});
|
||||
assert.equal(input, "/api/v1/media/ice");
|
||||
assert.equal(init?.method, "POST");
|
||||
@@ -41,6 +50,7 @@ describe("ephemeral ICE configuration client", () => {
|
||||
type: "ice-config-request",
|
||||
protocolVersion: 1,
|
||||
requestId: "ice-test-1",
|
||||
...authorization,
|
||||
});
|
||||
assert.deepEqual(result.configuration.iceServers, [
|
||||
{ urls: ["stun:relay.example.test:3478"] },
|
||||
@@ -59,6 +69,7 @@ describe("ephemeral ICE configuration client", () => {
|
||||
authenticatedFetch: async () => Response.json(body),
|
||||
now: () => 200,
|
||||
requestId: () => "ice-test-1",
|
||||
...authorization,
|
||||
});
|
||||
await assert.rejects(run({
|
||||
type: "ice-config-unavailable",
|
||||
@@ -98,6 +109,7 @@ describe("ephemeral ICE configuration client", () => {
|
||||
retryAfterMs: 12_345,
|
||||
}, { status }),
|
||||
requestId: () => "ice-backoff-1",
|
||||
...authorization,
|
||||
}), (error: unknown) => {
|
||||
assert.ok(error instanceof IceConfigurationUnavailableError);
|
||||
assert.equal(error.retryAfterMs, 12_345);
|
||||
@@ -114,11 +126,13 @@ describe("ephemeral ICE configuration client", () => {
|
||||
retryAfterMs: 12_345,
|
||||
}, { status: 503 }),
|
||||
requestId: () => "ice-backoff-1",
|
||||
...authorization,
|
||||
}), /did not match/);
|
||||
|
||||
await assert.rejects(fetchEphemeralIceConfiguration({
|
||||
authenticatedFetch: async () => Response.json({ retryAfterMs: 1 }, { status: 503 }),
|
||||
requestId: () => "ice-backoff-1",
|
||||
...authorization,
|
||||
}), /failed \(503\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
type IceConfigGrant,
|
||||
} from "../media/index.ts";
|
||||
|
||||
const request = () => ({
|
||||
type: "ice-config-request",
|
||||
protocolVersion: 1,
|
||||
requestId: "ice-request-1",
|
||||
binding: { officeId: "lumbridge-hq", levelId: "level-1", roomId: "lobby", screenId: "lobby-monitor" },
|
||||
credential: {
|
||||
sessionId: "opaque-session", participantId: "opaque-participant", role: "presenter", grantToken: "opaque-grant-token-value",
|
||||
},
|
||||
});
|
||||
|
||||
const grant = (): IceConfigGrant => ({
|
||||
type: "ice-config-grant",
|
||||
protocolVersion: 1,
|
||||
@@ -27,9 +37,7 @@ const grant = (): IceConfigGrant => ({
|
||||
|
||||
describe("ICE configuration wire contract", () => {
|
||||
it("accepts exact requests and bounded ephemeral grants", () => {
|
||||
assert.equal(parseIceConfigRequest({
|
||||
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1",
|
||||
}).ok, true);
|
||||
assert.equal(parseIceConfigRequest(request()).ok, true);
|
||||
assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true);
|
||||
assert.equal(isIceConfigGrantActive(grant(), 300_000), true);
|
||||
assert.equal(isIceConfigGrantActive(grant(), 601_000), false);
|
||||
@@ -53,9 +61,10 @@ describe("ICE configuration wire contract", () => {
|
||||
});
|
||||
|
||||
it("rejects extra keys, credential tricks, missing TURN, and excessive lifetime", () => {
|
||||
assert.equal(parseIceConfigRequest({
|
||||
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", subject: "stable-user",
|
||||
}).ok, false);
|
||||
assert.equal(parseIceConfigRequest({ ...request(), subject: "stable-user" }).ok, false);
|
||||
const { credential: _credential, ...withoutCredential } = request();
|
||||
assert.equal(parseIceConfigRequest(withoutCredential).ok, false);
|
||||
assert.equal(parseIceConfigRequest({ ...request(), binding: { ...request().binding, screenId: "bad id" } }).ok, false);
|
||||
assert.equal(parseIceConfigResponse({ ...grant(), identity: { profile: "karti" } }).ok, false);
|
||||
assert.equal(parseIceConfigResponse({ ...grant(), iceServers: [{ urls: ["stun:relay.example.test:3478"] }] }).ok, false);
|
||||
assert.equal(parseIceConfigResponse({ ...grant(), expiresAtMs: 3_601_001 }).ok, false);
|
||||
|
||||
@@ -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