1
0

security: bind TURN grants to screen sessions

This commit is contained in:
2026-08-11 21:48:11 -07:00
parent 5ca214e4bb
commit 19f022f71a
16 changed files with 530 additions and 93 deletions
+33 -5
View File
@@ -118,14 +118,39 @@ then rate-limit issuance by both subject and trusted client IP. The presenter or
viewer must join signaling before requesting ICE configuration. GET/query-string viewer must join signaling before requesting ICE configuration. GET/query-string
credentials remain forbidden. credentials remain forbidden.
Coturn removes the expiry prefix from a REST username before applying
`user-quota`. The suffix therefore must be a stable, opaque, session-participant
accounting key across repeat ICE issuance for that participant. Do not use the
authentication subject, email, profile id or another identity in this suffix,
and do not generate a fresh random suffix for every request: the former leaks
identity into TURN state/logs, while the latter gives every issuance a fresh
`user-quota`. A keyed digest of the opaque signaling session and participant ids
is an appropriate suffix. Application-side subject/IP issuance and concurrent
session limits remain mandatory; coturn cannot infer a Lumbridge member.
The template's `max-bps` and `bps-capacity` units are **bytes per second**, with
input and output accounted separately. In particular, `bps-capacity=500000000`
is a 500 MB/s (roughly 4 Gbit/s) ceiling, not a recommended launch budget.
Before public enablement, the operator must lower `total-quota` and
`bps-capacity` to the measured instance/network ceiling and an explicitly
accepted relay-egress budget. Start below that budget and raise only from
observed concurrent screen bitrate and allocation data. The 1,024-port relay
range is a hard capacity boundary, not evidence that 900 simultaneous relays are
operationally or financially safe.
An accepted response uses: An accepted response uses:
- username: `<expiry-unix-seconds>:<random-opaque-nonce>` - username: `<expiry-unix-seconds>:<stable-opaque-participant-accounting-key>`
- credential: Base64 HMAC-SHA1 of that username using `TERA_TURN_SHARED_SECRET` - credential: Base64 HMAC-SHA1 of that username using `TERA_TURN_SHARED_SECRET`
- response: `Cache-Control: private, no-store` - response: `Cache-Control: private, no-store`
The shared secret never leaves the server. Expiry prevents new allocations and The shared secret never leaves the server. Expiry prevents a credential from
refreshes; it cannot instantly terminate an allocation that already exists. starting a newly authenticated TURN session. It does not terminate an allocation
that already exists, and coturn may continue accepting authenticated refreshes
within that established session. `stale-nonce` rotates coturn's protocol nonce
(the browser handles the 438 challenge); it is not credential revocation. Stop
and revoke must still close browser peer connections, while coturn's total
allocation and bandwidth limits bound residual abuse.
Install `tera-coturn-preflight` as `/usr/local/libexec/tera-coturn-preflight` Install `tera-coturn-preflight` as `/usr/local/libexec/tera-coturn-preflight`
mode `0755`, and install the reviewed systemd drop-in only after confirming the mode `0755`, and install the reviewed systemd drop-in only after confirming the
@@ -205,10 +230,13 @@ Verify through `getStats()` or browser WebRTC diagnostics that:
- the relay address is the current public IP, never the private VNIC address; - the relay address is the current public IP, never the private VNIC address;
- two peers on different networks can exchange a screen track; - two peers on different networks can exchange a screen track;
- blocking UDP forces TURN/TCP 3478, then TURN/TLS 5349; - blocking UDP forces TURN/TCP 3478, then TURN/TLS 5349;
- expired TURN credentials cannot create or refresh allocations; - expired TURN credentials cannot start a new authenticated allocation;
- repeated ICE issuance for one signaling participant retains one opaque TURN
accounting suffix and the fifth concurrent allocation is refused by
`user-quota=4`, while a different participant remains independent;
- signaling revocation closes the application's peer connections, but an already-issued - signaling revocation closes the application's peer connections, but an already-issued
stateless TURN credential remains usable until its short expiry (and an existing stateless TURN credential remains usable until its short expiry (and an existing
allocation until coturn's configured allocation lifetime); authenticated allocation may remain refreshable until the client disconnects);
- presenter stop and viewer leave close their browser peer connections; - presenter stop and viewer leave close their browser peer connections;
- no CSP or Permissions Policy violation appears on either Tera entry host. - no CSP or Permissions Policy violation appears on either Tera entry host.
+6 -2
View File
@@ -28,10 +28,14 @@ pkey=/etc/coturn/certs/turn.privkey.pem
min-port=52000 min-port=52000
max-port=53023 max-port=53023
# One credential may briefly own several allocations during ICE restart. The # Coturn keys user-quota by the REST username suffix after the expiry separator.
# total stays below the 1,024-port relay range; tune only from observed usage. # The Tera issuer must reuse one opaque participant-scoped accounting suffix
# across that participant's credential renewals; a fresh suffix per request would
# evade this limit. The total stays below the 1,024-port relay range.
user-quota=4 user-quota=4
total-quota=900 total-quota=900
# Bytes per second, not bits. 500,000,000 B/s is about 4 Gbit/s; lower both
# global limits to an operator-approved NIC and egress-cost budget before launch.
max-bps=2000000 max-bps=2000000
bps-capacity=500000000 bps-capacity=500000000
+68 -28
View File
@@ -1,4 +1,4 @@
import { createHmac, randomBytes } from "node:crypto"; import { createHmac } from "node:crypto";
import { import {
ICE_CONFIG_PROTOCOL_VERSION, ICE_CONFIG_PROTOCOL_VERSION,
type IceConfigGrant, type IceConfigGrant,
@@ -13,13 +13,19 @@ export type IceCredentialResult =
| { ok: true; value: IceConfigGrant } | { ok: true; value: IceConfigGrant }
| { ok: false; code: "unavailable" | "rate-limited"; value: IceConfigUnavailable }; | { ok: false; code: "unavailable" | "rate-limited"; value: IceConfigUnavailable };
export interface IceCredentialRateIdentity {
/** Used transiently to derive a keyed rate bucket; never retained. */
subject: string;
/** Fastify's trusted client address; used transiently and never retained. */
trustedIp: string;
}
export interface IceCredentialProvider { export interface IceCredentialProvider {
issue(request: IceConfigRequest, rateKey: string): IceCredentialResult; issue(request: IceConfigRequest, rateIdentity: IceCredentialRateIdentity, authorizationExpiresAtMs: number): IceCredentialResult;
} }
export interface IceCredentialProviderOptions { export interface IceCredentialProviderOptions {
now?: () => number; now?: () => number;
nonce?: () => string;
} }
interface RateEntry { startedAtMs: number; count: number } interface RateEntry { startedAtMs: number; count: number }
@@ -30,37 +36,39 @@ export function createIceCredentialProvider(
options: IceCredentialProviderOptions = {}, options: IceCredentialProviderOptions = {},
): IceCredentialProvider { ): IceCredentialProvider {
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
const nonce = options.nonce ?? (() => randomBytes(18).toString("base64url")); const subjectRates = new Map<string, RateEntry>();
const rates = new Map<string, RateEntry>(); const ipRates = new Map<string, RateEntry>();
const windowMs = config.rateWindowSeconds * 1_000; const windowMs = config.rateWindowSeconds * 1_000;
return { return {
issue(request, rateKey) { issue(request, rateIdentity, authorizationExpiresAtMs) {
const at = now(); const at = now();
if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable"); if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable");
let entry = rates.get(rateKey); // Derive immediately and retain only keyed digests in the bounded maps.
if (!entry && rates.size >= MAX_RATE_KEYS) { const rateKeys = deriveIceCredentialRateKeys(config.sharedSecret, rateIdentity);
for (const [key, value] of rates) { const subjectRetry = rate(subjectRates, rateKeys.subject, at);
if (at - value.startedAtMs >= windowMs) rates.delete(key); const ipRetry = rate(ipRates, rateKeys.ip, at);
} const retryAfterMs = Math.max(subjectRetry ?? 0, ipRetry ?? 0);
if (rates.size >= MAX_RATE_KEYS) { if (retryAfterMs > 0) return unavailable(request.requestId, retryAfterMs, "rate-limited");
return unavailable(request.requestId, windowMs, "rate-limited");
}
entry = rates.get(rateKey);
}
if (!entry || at - entry.startedAtMs >= windowMs) {
rates.set(rateKey, { startedAtMs: at, count: 1 });
} else {
entry.count += 1;
if (entry.count > config.rateAttempts) {
return unavailable(request.requestId, Math.max(1_000, windowMs - (at - entry.startedAtMs)), "rate-limited");
}
}
const expiresAtSeconds = Math.floor(at / 1_000) + config.credentialTtlSeconds; const expiresAtSeconds = Math.min(
// Coturn REST convention: expiration timestamp, colon, opaque username. Math.floor(at / 1_000) + config.credentialTtlSeconds,
// No auth subject, email, profile id, IP address, or stable browser id. Math.floor(authorizationExpiresAtMs / 1_000),
const username = `${expiresAtSeconds}:${nonce()}`; );
if (expiresAtSeconds <= Math.floor(at / 1_000)) {
return unavailable(request.requestId, 1_000, "unavailable");
}
// Coturn applies user-quota after stripping the REST expiry prefix. A new
// random suffix per issuance would therefore evade quota. This keyed,
// domain-separated suffix is stable only for one opaque media participant
// and contains no auth subject, email, profile id, or IP address.
const opaqueParticipant = createHmac("sha256", config.sharedSecret)
.update("tera-turn-user-v1\0")
.update(request.credential.sessionId)
.update("\0")
.update(request.credential.participantId)
.digest("base64url");
const username = `${expiresAtSeconds}:${opaqueParticipant}`;
const credential = createHmac("sha1", config.sharedSecret).update(username).digest("base64"); const credential = createHmac("sha1", config.sharedSecret).update(username).digest("base64");
const stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:")); const stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:"));
const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:")); const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:"));
@@ -80,6 +88,38 @@ export function createIceCredentialProvider(
}; };
}, },
}; };
function rate(entries: Map<string, RateEntry>, key: string, at: number): number | null {
let entry = entries.get(key);
if (!entry && entries.size >= MAX_RATE_KEYS) {
for (const [candidate, value] of entries) {
if (at - value.startedAtMs >= windowMs) entries.delete(candidate);
}
if (entries.size >= MAX_RATE_KEYS) return windowMs;
entry = entries.get(key);
}
if (!entry || at - entry.startedAtMs >= windowMs) {
entries.set(key, { startedAtMs: at, count: 1 });
return null;
}
entry.count += 1;
return entry.count > config.rateAttempts
? Math.max(1_000, windowMs - (at - entry.startedAtMs))
: null;
}
}
/** Server-only keyed pseudonyms for the two independent limiter dimensions. */
export function deriveIceCredentialRateKeys(
sharedSecret: string,
identity: IceCredentialRateIdentity,
): { subject: string; ip: string } {
const derive = (domain: string, value: string) => createHmac("sha256", sharedSecret)
.update(domain).update("\0").update(value).digest("hex");
return {
subject: derive("tera-ice-subject-v1", identity.subject),
ip: derive("tera-ice-ip-v1", identity.trustedIp),
};
} }
function unavailable( function unavailable(
+2
View File
@@ -8,7 +8,9 @@ export {
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts"; export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
export { export {
createIceCredentialProvider, createIceCredentialProvider,
deriveIceCredentialRateKeys,
type IceCredentialProvider, type IceCredentialProvider,
type IceCredentialProviderOptions, type IceCredentialProviderOptions,
type IceCredentialRateIdentity,
type IceCredentialResult, type IceCredentialResult,
} from "./ice.ts"; } from "./ice.ts";
+15 -1
View File
@@ -46,6 +46,7 @@ export interface MediaSignalService {
signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null>; signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null>;
leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null>; leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null>;
subscribe(credential: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void>; subscribe(credential: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void>;
authorizeIce(credential: ScreenShareCredential, binding: ScreenShareBinding, subject: string): MediaSignalResult<{ expiresAtMs: number }>;
revalidate(credential: ScreenShareCredential, subject: string): MediaSignalResult<null>; revalidate(credential: ScreenShareCredential, subject: string): MediaSignalResult<null>;
revokeSubject(subject: string): number; revokeSubject(subject: string): number;
cleanup(): number; cleanup(): number;
@@ -373,6 +374,19 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
return found.ok ? { ok: true, value: null } : found; return found.ok ? { ok: true, value: null } : found;
} }
function authorizeIce(
raw: ScreenShareCredential,
binding: ScreenShareBinding,
subject: string,
): MediaSignalResult<{ expiresAtMs: number }> {
const found = authenticate(raw, subject);
if (!found.ok) return found;
if (!sameBinding(found.value.session.binding, binding)) {
return { ok: false, code: "unauthorized", message: "Media binding does not match the grant." };
}
return { ok: true, value: { expiresAtMs: found.value.participant.expiresAt } };
}
function revokeSubject(subject: string): number { function revokeSubject(subject: string): number {
let removed = 0; let removed = 0;
for (const session of [...sessions.values()]) { for (const session of [...sessions.values()]) {
@@ -409,7 +423,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
return removed; return removed;
} }
return { join, resume, signal, leave, subscribe, revalidate, revokeSubject, cleanup, sessionCount: () => sessions.size }; return { join, resume, signal, leave, subscribe, authorizeIce, revalidate, revokeSubject, cleanup, sessionCount: () => sessions.size };
} }
function opaque(bytes: number): string { return randomBytes(bytes).toString("base64url"); } function opaque(bytes: number): string { return randomBytes(bytes).toString("base64url"); }
+16 -2
View File
@@ -46,12 +46,26 @@ function parse(body: unknown) {
export function registerMedia(app: FastifyInstance, services: Services): void { export function registerMedia(app: FastifyInstance, services: Services): void {
app.post<{ Body: unknown }>("/api/v1/media/ice", { bodyLimit: 1_024 }, async (req, reply) => { app.post<{ Body: unknown }>("/api/v1/media/ice", { bodyLimit: 1_024 }, async (req, reply) => {
const viewer = await services.auth.resolve(req); const viewer = await services.auth.resolve(req);
if (!viewer.authenticated) { if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED); return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
} }
const parsed = parseIceConfigRequest(req.body); const parsed = parseIceConfigRequest(req.body);
if (!parsed.ok) return reply.code(400).send({ error: "invalid", message: "ICE configuration request is invalid." }); if (!parsed.ok) return reply.code(400).send({ error: "invalid", message: "ICE configuration request is invalid." });
const issued = services.ice.issue(parsed.value, req.ip); if (!(await bindingAllowed(parsed.value.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const authorization = services.media.authorizeIce(
parsed.value.credential,
parsed.value.binding,
viewer.subject,
);
if (!authorization.ok) return fail(reply, authorization);
// The provider immediately replaces these with separate keyed digests and
// retains neither the stable auth subject nor trusted proxy address.
const issued = services.ice.issue(parsed.value, {
subject: viewer.subject,
trustedIp: req.ip,
}, authorization.value.expiresAtMs);
if (!issued.ok) { if (!issued.ok) {
const statusCode = issued.code === "rate-limited" ? 429 : 503; const statusCode = issued.code === "rate-limited" ? 429 : 503;
return reply.code(statusCode) return reply.code(statusCode)
+151 -23
View File
@@ -1,11 +1,13 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createHmac } from "node:crypto"; import { createHash, createHmac } from "node:crypto";
import { after, describe, it } from "node:test"; import { after, describe, it } from "node:test";
import { buildApp } from "../app.ts"; import { buildApp } from "../app.ts";
import { loadConfig, type IceConfig } from "../config.ts"; import { loadConfig, type IceConfig } from "../config.ts";
import { createIceCredentialProvider } from "../media/index.ts"; import { createIceCredentialProvider, deriveIceCredentialRateKeys } from "../media/index.ts";
import { parseIceConfigResponse } from "../../../src/media/iceValidation.ts"; import { parseIceConfigResponse } from "../../../src/media/iceValidation.ts";
import type { IceConfigGrant } from "../../../src/media/iceTypes.ts"; import type { IceConfigGrant } from "../../../src/media/iceTypes.ts";
import type { ScreenShareCredential } from "../../../src/media/signalingTypes.ts";
import type { ScreenShareBinding } from "../../../src/media/signalingTypes.ts";
const SHARED_SECRET = "turn-shared-secret-with-at-least-thirty-two-bytes"; const SHARED_SECRET = "turn-shared-secret-with-at-least-thirty-two-bytes";
const JWT_SECRET = "ice-route-jwt-secret-that-is-long-enough"; const JWT_SECRET = "ice-route-jwt-secret-that-is-long-enough";
@@ -20,7 +22,16 @@ const configured = (overrides: Partial<IceConfig> = {}): IceConfig => ({
...overrides, ...overrides,
}); });
const request = { type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1" } as const; const BINDING = { officeId: "lumbridge-hq", levelId: "level-1", roomId: "lobby", screenId: "lobby-monitor" } as const;
const SECOND_BINDING = { officeId: "lumbridge-hq", levelId: "level-1", roomId: "open-floor", screenId: "open-display" } as const;
const PROVIDER_CREDENTIAL: ScreenShareCredential = {
sessionId: "opaque-session", participantId: "opaque-participant", role: "presenter", grantToken: "opaque-grant-token-value",
};
const iceRequest = (credential: ScreenShareCredential = PROVIDER_CREDENTIAL, binding: ScreenShareBinding = BINDING) => ({
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", binding, credential,
} as const);
const request = iceRequest();
const rateKeys = (subject = "auth-subject", trustedIp = "203.0.113.8") => ({ subject, trustedIp });
function bearer(subject: string) { function bearer(subject: string) {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
@@ -29,18 +40,20 @@ function bearer(subject: string) {
} }
describe("coturn REST ICE credential provider", () => { describe("coturn REST ICE credential provider", () => {
it("uses an opaque nonce and the standard expiration:HMAC-SHA1 credential", () => { it("uses an opaque quota identity and the standard expiration:HMAC-SHA1 credential", () => {
const provider = createIceCredentialProvider(configured(), { const provider = createIceCredentialProvider(configured(), {
now: () => 1_700_000_000_500, now: () => 1_700_000_000_500,
nonce: () => "opaque_random_nonce_1234",
}); });
const result = provider.issue(request, "rate-key"); const result = provider.issue(request, rateKeys(), 1_700_000_090_000);
assert.equal(result.ok, true); assert.equal(result.ok, true);
if (!result.ok) return; if (!result.ok) return;
const turn = result.value.iceServers.find((server) => "username" in server); const turn = result.value.iceServers.find((server) => "username" in server);
assert.ok(turn && "username" in turn); assert.ok(turn && "username" in turn);
if (!turn || !("username" in turn)) return; if (!turn || !("username" in turn)) return;
assert.equal(turn.username, "1700000600:opaque_random_nonce_1234"); const suffix = createHmac("sha256", SHARED_SECRET)
.update("tera-turn-user-v1\0").update(request.credential.sessionId)
.update("\0").update(request.credential.participantId).digest("base64url");
assert.equal(turn.username, `1700000090:${suffix}`);
assert.equal(turn.credential, createHmac("sha1", SHARED_SECRET).update(turn.username).digest("base64")); assert.equal(turn.credential, createHmac("sha1", SHARED_SECRET).update(turn.username).digest("base64"));
assert.equal(turn.username.includes("auth-subject"), false); assert.equal(turn.username.includes("auth-subject"), false);
assert.equal(JSON.stringify(result.value).includes(SHARED_SECRET), false); assert.equal(JSON.stringify(result.value).includes(SHARED_SECRET), false);
@@ -49,32 +62,79 @@ describe("coturn REST ICE credential provider", () => {
it("fails closed when unconfigured and rate bounds issuance", () => { it("fails closed when unconfigured and rate bounds issuance", () => {
const unavailable = createIceCredentialProvider(configured({ configured: false, sharedSecret: "", urls: [] })); const unavailable = createIceCredentialProvider(configured({ configured: false, sharedSecret: "", urls: [] }));
const closed = unavailable.issue(request, "caller"); const closed = unavailable.issue(request, rateKeys(), Date.now() + 90_000);
assert.equal(closed.ok, false); assert.equal(closed.ok, false);
if (!closed.ok) assert.equal(closed.code, "unavailable"); if (!closed.ok) assert.equal(closed.code, "unavailable");
const limited = createIceCredentialProvider(configured({ rateAttempts: 1 }), { const limited = createIceCredentialProvider(configured({ rateAttempts: 1 }), {
now: () => 10_000, nonce: () => "opaque_random_nonce_1234", now: () => 10_000,
}); });
assert.equal(limited.issue(request, "same-ip").ok, true); assert.equal(limited.issue(request, rateKeys(), 100_000).ok, true);
const second = limited.issue(request, "same-ip"); const second = limited.issue(request, rateKeys(), 100_000);
assert.equal(second.ok, false); assert.equal(second.ok, false);
if (!second.ok) assert.equal(second.code, "rate-limited"); if (!second.ok) assert.equal(second.code, "rate-limited");
assert.equal(limited.issue(request, "other-ip").ok, true); assert.equal(limited.issue(request, rateKeys("other-subject", "other-ip"), 100_000).ok, true);
});
it("enforces independent subject and trusted-IP buckets", () => {
const bySubject = createIceCredentialProvider(configured({ rateAttempts: 1 }), { now: () => 10_000 });
assert.equal(bySubject.issue(request, rateKeys("subject-a", "ip-a"), 100_000).ok, true);
const rotatedIp = bySubject.issue(request, rateKeys("subject-a", "ip-b"), 100_000);
assert.equal(rotatedIp.ok, false, "changing address cannot evade a subject limit");
const byIp = createIceCredentialProvider(configured({ rateAttempts: 1 }), { now: () => 10_000 });
assert.equal(byIp.issue(request, rateKeys("subject-a", "shared-ip"), 100_000).ok, true);
const sharedNat = byIp.issue(request, rateKeys("subject-b", "shared-ip"), 100_000);
assert.equal(sharedNat.ok, false, "changing account cannot evade an address limit");
});
it("retains keyed domain-separated rate pseudonyms, not raw or plain hashes", () => {
const identity = rateKeys("stable-auth-subject", "203.0.113.42");
const first = deriveIceCredentialRateKeys(SHARED_SECRET, identity);
const second = deriveIceCredentialRateKeys(`${SHARED_SECRET}-rotated`, identity);
assert.notEqual(first.subject, identity.subject);
assert.notEqual(first.ip, identity.trustedIp);
assert.notEqual(first.subject, first.ip);
assert.notDeepEqual(first, second, "server secret rotation changes both pseudonyms");
assert.notEqual(first.subject, createHash("sha256").update(`tera-ice-subject-v1\0${identity.subject}`).digest("hex"));
assert.notEqual(first.ip, createHash("sha256").update(`tera-ice-ip-v1\0${identity.trustedIp}`).digest("hex"));
}); });
it("keeps the client-rate table bounded under address churn", () => { it("keeps the client-rate table bounded under address churn", () => {
let time = 10_000; let time = 10_000;
const provider = createIceCredentialProvider(configured({ rateAttempts: 2 }), { const provider = createIceCredentialProvider(configured({ rateAttempts: 2 }), {
now: () => time, nonce: () => "opaque_random_nonce_1234", now: () => time,
}); });
for (let index = 0; index < 4_096; index += 1) { for (let index = 0; index < 4_096; index += 1) {
assert.equal(provider.issue(request, `client-${index}`).ok, true); assert.equal(provider.issue(request, rateKeys(`subject-${index}`, `ip-${index}`), 100_000).ok, true);
} }
const full = provider.issue(request, "client-over-cap"); const full = provider.issue(request, rateKeys("subject-over-cap", "ip-over-cap"), 100_000);
assert.equal(full.ok, false); assert.equal(full.ok, false);
if (!full.ok) assert.equal(full.code, "rate-limited"); if (!full.ok) assert.equal(full.code, "rate-limited");
time += 60_000; time += 60_000;
assert.equal(provider.issue(request, "client-after-window").ok, true); assert.equal(provider.issue(request, rateKeys("subject-after-window", "ip-after-window"), 200_000).ok, true);
});
it("keeps the quota suffix stable per opaque participant and distinct across participants", () => {
let time = 1_700_000_000_500;
const provider = createIceCredentialProvider(configured({ rateAttempts: 10 }), { now: () => time });
const first = provider.issue(request, rateKeys("subject-a", "ip-a"), 1_700_000_090_000);
time += 1_000;
const second = provider.issue(request, rateKeys("subject-a", "ip-a"), 1_700_000_090_000);
const another = provider.issue(
iceRequest({ ...request.credential, participantId: "another-opaque-participant" }),
rateKeys("subject-b", "ip-b"),
1_700_000_090_000,
);
assert.ok(first.ok && second.ok && another.ok);
if (!first.ok || !second.ok || !another.ok) return;
const username = (value: IceConfigGrant) => {
const turn = value.iceServers.find((server) => "username" in server);
assert.ok(turn && "username" in turn);
return turn && "username" in turn ? turn.username : "";
};
const suffix = (value: IceConfigGrant) => username(value).slice(username(value).indexOf(":") + 1);
assert.equal(suffix(first.value), suffix(second.value));
assert.notEqual(suffix(first.value), suffix(another.value));
}); });
it("strictly disables partial or malformed environment configuration", () => { it("strictly disables partial or malformed environment configuration", () => {
@@ -97,7 +157,7 @@ describe("coturn REST ICE credential provider", () => {
}); });
describe("authenticated ICE route", () => { describe("authenticated ICE route", () => {
it("returns private ephemeral credentials only to authenticated POST callers", async () => { it("issues private ephemeral credentials to active presenter and viewer grants", async () => {
const config = loadConfig({ const config = loadConfig({
TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET, TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET,
TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET, TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
@@ -106,13 +166,21 @@ describe("authenticated ICE route", () => {
const app = buildApp(config); after(() => app.close()); const app = buildApp(config); after(() => app.close());
assert.equal((await app.inject({ method: "GET", url: "/api/v1/media/ice" })).statusCode, 404); assert.equal((await app.inject({ method: "GET", url: "/api/v1/media/ice" })).statusCode, 404);
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", payload: request })).statusCode, 401); assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", payload: request })).statusCode, 401);
const presenterJoin = await app.inject({
method: "POST", url: "/api/v1/media/join", headers: bearer("presenter-subject"), payload: {
type: "screen-share-create-request", protocolVersion: 1, requestId: "create-1", sequence: 1,
timestampMs: 1_000, binding: BINDING, role: "presenter",
},
});
const presenter = presenterJoin.json<{ grant: { credential: ScreenShareCredential } }>();
const malformed = await app.inject({ const malformed = await app.inject({
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter-subject"),
payload: { ...request, profile: "do-not-send" }, payload: { ...iceRequest(presenter.grant.credential), profile: "do-not-send" },
}); });
assert.equal(malformed.statusCode, 400); assert.equal(malformed.statusCode, 400);
const response = await app.inject({ const response = await app.inject({
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: request, method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter-subject"),
payload: iceRequest(presenter.grant.credential),
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.equal(response.headers["cache-control"], "private, no-store"); assert.equal(response.headers["cache-control"], "private, no-store");
@@ -120,17 +188,77 @@ describe("authenticated ICE route", () => {
assert.equal(response.headers.location, undefined); assert.equal(response.headers.location, undefined);
const body = response.json<IceConfigGrant>(); const body = response.json<IceConfigGrant>();
assert.equal(parseIceConfigResponse(body).ok, true); assert.equal(parseIceConfigResponse(body).ok, true);
assert.equal(JSON.stringify(body).includes("auth-subject"), false); assert.equal(JSON.stringify(body).includes("presenter-subject"), false);
assert.ok(body.expiresAtMs <= Date.now() + 90_000, "TURN credential cannot outlive the signaling grant");
const viewerJoin = await app.inject({
method: "POST", url: "/api/v1/media/join", headers: bearer("viewer-subject"), payload: {
type: "screen-share-join-request", protocolVersion: 1, requestId: "join-1", sequence: 1,
timestampMs: 1_001, binding: BINDING, role: "viewer", viewerOptIn: true,
},
});
const viewer = viewerJoin.json<{ grant: { credential: ScreenShareCredential } }>();
const viewerIce = await app.inject({
method: "POST", url: "/api/v1/media/ice", headers: bearer("viewer-subject"),
payload: iceRequest(viewer.grant.credential),
});
assert.equal(viewerIce.statusCode, 200);
});
it("rejects missing, stolen, wrong-binding, stopped, and revoked signaling grants", async () => {
const config = loadConfig({
TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET,
TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET,
});
config.logLevel = "silent";
const app = buildApp(config); after(() => app.close());
const createPayload = {
type: "screen-share-create-request", protocolVersion: 1, requestId: "create-negative", sequence: 1,
timestampMs: 1_000, binding: BINDING, role: "presenter",
};
const created = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("presenter"), payload: createPayload });
const presenter = created.json<{ grant: { credential: ScreenShareCredential } }>().grant.credential;
const missing = { ...iceRequest(presenter) } as Record<string, unknown>;
delete missing.credential;
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: missing })).statusCode, 400);
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("thief"), payload: iceRequest(presenter) })).statusCode, 401);
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: iceRequest(presenter, SECOND_BINDING) })).statusCode, 401);
const joined = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("viewer"), payload: {
type: "screen-share-join-request", protocolVersion: 1, requestId: "join-negative", sequence: 1,
timestampMs: 1_001, binding: BINDING, role: "viewer", viewerOptIn: true,
} });
const viewer = joined.json<{ grant: { credential: ScreenShareCredential } }>().grant.credential;
const revoked = await app.inject({ method: "POST", url: "/api/v1/media/leave", headers: bearer("viewer"), payload: {
type: "screen-share-revoke-request", protocolVersion: 1, requestId: "viewer-left", sequence: 2,
timestampMs: 1_002, binding: BINDING, credential: viewer, scope: "participant",
targetParticipantId: viewer.participantId, reason: "viewer-left",
} });
assert.equal(revoked.statusCode, 204);
const revokedIce = await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("viewer"), payload: iceRequest(viewer) });
assert.equal(revokedIce.statusCode, 401, revokedIce.body);
const stopped = await app.inject({ method: "POST", url: "/api/v1/media/leave", headers: bearer("presenter"), payload: {
type: "screen-share-stop-request", protocolVersion: 1, requestId: "presenter-stop", sequence: 2,
timestampMs: 1_003, binding: BINDING, credential: presenter, reason: "presenter-stopped",
} });
assert.equal(stopped.statusCode, 204);
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: iceRequest(presenter) })).statusCode, 401);
}); });
it("returns typed unavailable without emitting credentials when TURN is absent", async () => { it("returns typed unavailable without emitting credentials when TURN is absent", async () => {
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET }); const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET });
config.logLevel = "silent"; config.logLevel = "silent";
const app = buildApp(config); after(() => app.close()); const app = buildApp(config); after(() => app.close());
const created = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("auth-subject"), payload: {
type: "screen-share-create-request", protocolVersion: 1, requestId: "create-unavailable", sequence: 1,
timestampMs: 1_000, binding: BINDING, role: "presenter",
} });
const credential = created.json<{ grant: { credential: ScreenShareCredential } }>().grant.credential;
const response = await app.inject({ const response = await app.inject({
method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: request, method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: iceRequest(credential),
}); });
assert.equal(response.statusCode, 503); assert.equal(response.statusCode, 503, response.body);
assert.equal(response.json().type, "ice-config-unavailable"); assert.equal(response.json().type, "ice-config-unavailable");
assert.equal(JSON.stringify(response.json()).includes("credential"), false); assert.equal(JSON.stringify(response.json()).includes("credential"), false);
}); });
+17
View File
@@ -76,6 +76,23 @@ describe("office media signaling service", () => {
assert.equal(service.sessionCount(), 0); assert.equal(service.sessionCount(), 0);
}); });
it("authorizes ICE only for a live grant owner on its exact screen binding", () => {
let now = 10_000;
const service = createMediaSignalService({ now: () => now, grantTtlMs: 100 });
const presenter = service.join(create(), "presenter-subject");
assert.equal(presenter.ok, true);
if (!presenter.ok || presenter.value.type !== "screen-share-create-grant") return;
const credential = presenter.value.grant.credential;
assert.deepEqual(service.authorizeIce(credential, BINDING, "presenter-subject"), {
ok: true, value: { expiresAtMs: 10_100 },
});
assert.equal(service.authorizeIce(credential, BINDING, "wrong-subject").ok, false);
assert.equal(service.authorizeIce(credential, { ...BINDING, screenId: "another-screen" }, "presenter-subject").ok, false);
now = 10_101;
assert.equal(service.authorizeIce(credential, BINDING, "presenter-subject").ok, false,
"expired signaling grants cannot mint fresh TURN credentials");
});
it("delivers a queued offer after resume with a sequence newer than the grant", () => { it("delivers a queued offer after resume with a sequence newer than the grant", () => {
const service = createMediaSignalService(); const service = createMediaSignalService();
const { presenter, viewer } = grants(service); const { presenter, viewer } = grants(service);
+2 -16
View File
@@ -2365,17 +2365,10 @@ async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void>
remotePanelStatus(surface.screenId, "connecting"); remotePanelStatus(surface.screenId, "connecting");
showDetail(`Connecting to ${surface.screenId}`); showDetail(`Connecting to ${surface.screenId}`);
try { try {
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([ const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
import("./media/remoteMedia.ts"),
import("./media/iceClient.ts"),
]);
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false; const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office || if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
office.depth !== "full" || officeId !== surface.officeId || !optedIn) return; 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"); const video = document.createElement("video");
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
@@ -2388,7 +2381,6 @@ async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void>
role: "viewer", role: "viewer",
binding: screenBinding(surface), binding: screenBinding(surface),
authenticatedFetch: authFetch, authenticatedFetch: authFetch,
peerConnectionConfiguration: ice.configuration,
onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); }, onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); },
onError: (error) => { onError: (error) => {
if (remoteViewedScreen !== viewing) return; if (remoteViewedScreen !== viewing) return;
@@ -2423,18 +2415,12 @@ async function startRemotePresenter(
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
remotePanelStatus(surface.screenId, "connecting"); remotePanelStatus(surface.screenId, "connecting");
try { try {
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([ const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
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 });
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
const remote = createRemoteOfficeMedia({ const remote = createRemoteOfficeMedia({
role: "presenter", role: "presenter",
binding: screenBinding(surface), binding: screenBinding(surface),
authenticatedFetch: authFetch, authenticatedFetch: authFetch,
peerConnectionConfiguration: ice.configuration,
onStateChange(state) { onStateChange(state) {
if (sharedScreen !== shared) return; if (sharedScreen !== shared) return;
if (state.status === "live") remotePanelStatus(surface.screenId, "live"); if (state.status === "live") remotePanelStatus(surface.screenId, "live");
+6
View File
@@ -1,5 +1,6 @@
import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts"; import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts";
import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts"; import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts";
import type { ScreenShareBinding, ScreenShareCredential } from "./signalingTypes.ts";
export interface EphemeralIceConfiguration { export interface EphemeralIceConfiguration {
readonly configuration: RTCConfiguration; readonly configuration: RTCConfiguration;
@@ -8,6 +9,9 @@ export interface EphemeralIceConfiguration {
export interface FetchEphemeralIceConfigurationOptions { export interface FetchEphemeralIceConfigurationOptions {
authenticatedFetch: typeof globalThis.fetch; authenticatedFetch: typeof globalThis.fetch;
/** Exact server-authored screen and active signaling capability. */
binding: ScreenShareBinding;
credential: ScreenShareCredential;
endpoint?: string; endpoint?: string;
now?: () => number; now?: () => number;
requestId?: () => string; requestId?: () => string;
@@ -49,6 +53,8 @@ export async function fetchEphemeralIceConfiguration(
type: "ice-config-request", type: "ice-config-request",
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION, protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
requestId: id, requestId: id,
binding: { ...options.binding },
credential: { ...options.credential },
}), }),
}); });
let body: unknown; let body: unknown;
+4
View File
@@ -1,11 +1,15 @@
/** JSON-only contract for fetching ephemeral WebRTC ICE configuration. */ /** 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 const ICE_CONFIG_PROTOCOL_VERSION = 1 as const;
export interface IceConfigRequest { export interface IceConfigRequest {
type: "ice-config-request"; type: "ice-config-request";
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION; protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
requestId: string; requestId: string;
binding: ScreenShareBinding;
credential: ScreenShareCredential;
} }
export interface StunIceServer { export interface StunIceServer {
+17 -2
View File
@@ -35,12 +35,27 @@ export function isSafeIceUrl(value: unknown): value is string {
} }
export function parseIceConfigRequest(value: unknown): IceConfigValidationResult<IceConfigRequest> { 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 || 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); 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> { export function parseIceConfigResponse(value: unknown): IceConfigValidationResult<IceConfigResponse> {
if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) { if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) {
return failure("invalid response envelope"); return failure("invalid response envelope");
+65 -7
View File
@@ -19,6 +19,10 @@ import {
type ScreenShareStreamCursor, type ScreenShareStreamCursor,
} from "./signalingTypes.ts"; } from "./signalingTypes.ts";
import type { MediaAuthorizationDecision } from "./types.ts"; import type { MediaAuthorizationDecision } from "./types.ts";
import {
fetchEphemeralIceConfiguration,
type EphemeralIceConfiguration,
} from "./iceClient.ts";
export type RemoteMediaRole = ScreenShareRole; export type RemoteMediaRole = ScreenShareRole;
export type RemoteMediaStatus = "idle" | "connecting" | "live" | "reconnecting" | "stopped" | "revoked" | "disposed"; export type RemoteMediaStatus = "idle" | "connecting" | "live" | "reconnecting" | "stopped" | "revoked" | "disposed";
@@ -33,7 +37,7 @@ export interface RemoteMediaState {
reconnectAttempt: number; reconnectAttempt: number;
hasRemoteVideo: boolean; 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 { export interface RemoteMediaTimers {
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>; setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void; clearTimeout(handle: ReturnType<typeof setTimeout>): void;
@@ -45,7 +49,11 @@ export interface RemoteOfficeMediaOptions {
authenticatedFetch?: typeof globalThis.fetch; authenticatedFetch?: typeof globalThis.fetch;
fetch?: typeof globalThis.fetch; fetch?: typeof globalThis.fetch;
peerConnectionFactory?: (configuration?: RTCConfiguration) => RTCPeerConnection; 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>; endpoints?: Partial<RemoteMediaEndpoints>;
timers?: RemoteMediaTimers; timers?: RemoteMediaTimers;
now?: () => number; now?: () => number;
@@ -95,6 +103,7 @@ const ENDPOINTS: RemoteMediaEndpoints = {
signal: "/api/v1/media/signal", signal: "/api/v1/media/signal",
events: "/api/v1/media/events", events: "/api/v1/media/events",
leave: "/api/v1/media/leave", leave: "/api/v1/media/leave",
ice: "/api/v1/media/ice",
}; };
const TIMERS: RemoteMediaTimers = { const TIMERS: RemoteMediaTimers = {
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay), setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
@@ -144,6 +153,14 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
const reconnectBase = options.reconnectBaseMs ?? 500; const reconnectBase = options.reconnectBaseMs ?? 500;
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000; 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) { if (!(reconnectBase > 0) || !Number.isFinite(reconnectBase) || reconnectMaximum < reconnectBase) {
throw new RangeError("remote media: invalid reconnect bounds"); throw new RangeError("remote media: invalid reconnect bounds");
} }
@@ -157,6 +174,8 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
let presenter: PresenterStart | null = null; let presenter: PresenterStart | null = null;
let viewer: ViewerStart | null = null; let viewer: ViewerStart | null = null;
let receiverStream: MediaStream | null = null; let receiverStream: MediaStream | null = null;
let iceConfiguration: EphemeralIceConfiguration | null = null;
let iceAuthorization: ScreenShareCredential | null = null;
let eventAbort: AbortController | null = null; let eventAbort: AbortController | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let renewalTimer: ReturnType<typeof setTimeout> | null = null; let renewalTimer: ReturnType<typeof setTimeout> | null = null;
@@ -225,6 +244,8 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
renewalTimer = null; renewalTimer = null;
for (const id of [...peers.keys()]) closePeer(id); for (const id of [...peers.keys()]) closePeer(id);
releaseReceiver(); releaseReceiver();
iceConfiguration = null;
iceAuthorization = null;
} }
function resetPeerMesh(): void { function resetPeerMesh(): void {
@@ -232,6 +253,33 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
releaseReceiver(); 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> { async function sendSignal(targetParticipantId: string, signal: ScreenShareSignalPayload): Promise<void> {
if (!grant) throw new Error("remote media: signaling grant is missing"); if (!grant) throw new Error("remote media: signaling grant is missing");
const request: ScreenShareClientMessage = { const request: ScreenShareClientMessage = {
@@ -250,7 +298,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
const prior = peers.get(participant.participantId); const prior = peers.get(participant.participantId);
if (prior) return prior; if (prior) return prior;
const activeGeneration = generation; 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: [] }; const slot: PeerSlot = { id: participant.participantId, connection, pendingIce: [] };
peers.set(slot.id, slot); peers.set(slot.id, slot);
connection.onicecandidate = (event) => { connection.onicecandidate = (event) => {
@@ -305,6 +356,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
const ids = new Set(allowed.map((item) => item.participantId)); const ids = new Set(allowed.map((item) => item.participantId));
for (const id of [...peers.keys()]) if (!ids.has(id)) closePeer(id); for (const id of [...peers.keys()]) if (!ids.has(id)) closePeer(id);
participants = allowed; participants = allowed;
if (allowed.some((participant) => !peers.has(participant.participantId))) await ensureIceConfiguration();
if (options.role === "presenter") { if (options.role === "presenter") {
for (const participant of allowed) if (!peers.has(participant.participantId)) await offer(participant); for (const participant of allowed) if (!peers.has(participant.participantId)) await offer(participant);
} else { } else {
@@ -343,6 +395,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
if (message.targetParticipantId !== grant.credential.participantId) return; if (message.targetParticipantId !== grant.credential.participantId) return;
const from = participants.find((item) => item.participantId === message.fromParticipantId); const from = participants.find((item) => item.participantId === message.fromParticipantId);
if (!from) return; if (!from) return;
if (!peers.has(from.participantId)) await ensureIceConfiguration();
const connection = configurePeer(from).connection; const connection = configurePeer(from).connection;
if (message.signal.kind === "ice-complete") { if (message.signal.kind === "ice-complete") {
if (connection.remoteDescription) await connection.addIceCandidate(null); if (connection.remoteDescription) await connection.addIceCandidate(null);
@@ -508,6 +561,9 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
grant = response.grant; grant = response.grant;
clientSequence = response.nextClientSequence; clientSequence = response.nextClientSequence;
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs }; 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); await reconcile(response.participants);
scheduleRenewal(); scheduleRenewal();
if (role === "presenter" && peers.size === 0) setStatus("live"); if (role === "presenter" && peers.size === 0) setStatus("live");
@@ -532,8 +588,11 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
await join("presenter", generation); await join("presenter", generation);
} catch (reason) { } catch (reason) {
if (status === "connecting") { if (status === "connecting") {
closeTransport(); // A join can succeed while the grant-bound TURN exchange fails. End
grant = null; // 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; presenter = null;
setStatus("idle"); setStatus("idle");
} }
@@ -564,8 +623,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
await join("viewer", generation); await join("viewer", generation);
} catch (reason) { } catch (reason) {
if (status === "connecting") { if (status === "connecting") {
closeTransport(); try { await leave(); } catch { closeTransport(); grant = null; participants = []; }
grant = null;
viewer = null; viewer = null;
setStatus("idle"); setStatus("idle");
} }
+14
View File
@@ -5,6 +5,14 @@ import {
IceConfigurationUnavailableError, IceConfigurationUnavailableError,
} from "../media/iceClient.ts"; } 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", () => { describe("ephemeral ICE configuration client", () => {
it("posts the exact authenticated request and returns an in-memory RTC configuration", async () => { it("posts the exact authenticated request and returns an in-memory RTC configuration", async () => {
let input: RequestInfo | URL | undefined; let input: RequestInfo | URL | undefined;
@@ -32,6 +40,7 @@ describe("ephemeral ICE configuration client", () => {
}, },
now: () => 200, now: () => 200,
requestId: () => "ice-test-1", requestId: () => "ice-test-1",
...authorization,
}); });
assert.equal(input, "/api/v1/media/ice"); assert.equal(input, "/api/v1/media/ice");
assert.equal(init?.method, "POST"); assert.equal(init?.method, "POST");
@@ -41,6 +50,7 @@ describe("ephemeral ICE configuration client", () => {
type: "ice-config-request", type: "ice-config-request",
protocolVersion: 1, protocolVersion: 1,
requestId: "ice-test-1", requestId: "ice-test-1",
...authorization,
}); });
assert.deepEqual(result.configuration.iceServers, [ assert.deepEqual(result.configuration.iceServers, [
{ urls: ["stun:relay.example.test:3478"] }, { urls: ["stun:relay.example.test:3478"] },
@@ -59,6 +69,7 @@ describe("ephemeral ICE configuration client", () => {
authenticatedFetch: async () => Response.json(body), authenticatedFetch: async () => Response.json(body),
now: () => 200, now: () => 200,
requestId: () => "ice-test-1", requestId: () => "ice-test-1",
...authorization,
}); });
await assert.rejects(run({ await assert.rejects(run({
type: "ice-config-unavailable", type: "ice-config-unavailable",
@@ -98,6 +109,7 @@ describe("ephemeral ICE configuration client", () => {
retryAfterMs: 12_345, retryAfterMs: 12_345,
}, { status }), }, { status }),
requestId: () => "ice-backoff-1", requestId: () => "ice-backoff-1",
...authorization,
}), (error: unknown) => { }), (error: unknown) => {
assert.ok(error instanceof IceConfigurationUnavailableError); assert.ok(error instanceof IceConfigurationUnavailableError);
assert.equal(error.retryAfterMs, 12_345); assert.equal(error.retryAfterMs, 12_345);
@@ -114,11 +126,13 @@ describe("ephemeral ICE configuration client", () => {
retryAfterMs: 12_345, retryAfterMs: 12_345,
}, { status: 503 }), }, { status: 503 }),
requestId: () => "ice-backoff-1", requestId: () => "ice-backoff-1",
...authorization,
}), /did not match/); }), /did not match/);
await assert.rejects(fetchEphemeralIceConfiguration({ await assert.rejects(fetchEphemeralIceConfiguration({
authenticatedFetch: async () => Response.json({ retryAfterMs: 1 }, { status: 503 }), authenticatedFetch: async () => Response.json({ retryAfterMs: 1 }, { status: 503 }),
requestId: () => "ice-backoff-1", requestId: () => "ice-backoff-1",
...authorization,
}), /failed \(503\)/); }), /failed \(503\)/);
}); });
}); });
+15 -6
View File
@@ -8,6 +8,16 @@ import {
type IceConfigGrant, type IceConfigGrant,
} from "../media/index.ts"; } 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 => ({ const grant = (): IceConfigGrant => ({
type: "ice-config-grant", type: "ice-config-grant",
protocolVersion: 1, protocolVersion: 1,
@@ -27,9 +37,7 @@ const grant = (): IceConfigGrant => ({
describe("ICE configuration wire contract", () => { describe("ICE configuration wire contract", () => {
it("accepts exact requests and bounded ephemeral grants", () => { it("accepts exact requests and bounded ephemeral grants", () => {
assert.equal(parseIceConfigRequest({ assert.equal(parseIceConfigRequest(request()).ok, true);
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1",
}).ok, true);
assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true); assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true);
assert.equal(isIceConfigGrantActive(grant(), 300_000), true); assert.equal(isIceConfigGrantActive(grant(), 300_000), true);
assert.equal(isIceConfigGrantActive(grant(), 601_000), false); 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", () => { it("rejects extra keys, credential tricks, missing TURN, and excessive lifetime", () => {
assert.equal(parseIceConfigRequest({ assert.equal(parseIceConfigRequest({ ...request(), subject: "stable-user" }).ok, false);
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", subject: "stable-user", const { credential: _credential, ...withoutCredential } = request();
}).ok, false); 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(), identity: { profile: "karti" } }).ok, false);
assert.equal(parseIceConfigResponse({ ...grant(), iceServers: [{ urls: ["stun:relay.example.test:3478"] }] }).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); assert.equal(parseIceConfigResponse({ ...grant(), expiresAtMs: 3_601_001 }).ok, false);
+99 -1
View File
@@ -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 { function sse(messages: readonly unknown[]): Response {
const bytes = new TextEncoder().encode(messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join("")); const bytes = new TextEncoder().encode(messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join(""));
return new Response(new ReadableStream<Uint8Array>({ return new Response(new ReadableStream<Uint8Array>({
@@ -165,6 +187,8 @@ describe("remote office media presenter", () => {
const fetcher: typeof fetch = async (input, init = {}) => { const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>; const body = JSON.parse(String(init.body)) as Record<string, unknown>;
calls.push({ url: String(input), init, body }); calls.push({ url: String(input), init, body });
const ice = iceResponse(input, body);
if (ice) return ice;
if (String(input).endsWith("/join")) { if (String(input).endsWith("/join")) {
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 }); 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.autoplay, false);
assert.equal(video.muted, true); assert.equal(video.muted, true);
const signal = calls.find((call) => call.url.endsWith("/signal")); 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?.body.signal as { kind?: string }).kind, "sdp");
assert.equal(signal?.init.credentials, "same-origin"); assert.equal(signal?.init.credentials, "same-origin");
assert.equal(calls.some((call) => call.url.includes("private-presenter-grant")), false); 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 () => { it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
const timers = new FakeTimers(); const timers = new FakeTimers();
const peers: FakePeer[] = []; const peers: FakePeer[] = [];
const iceTokens: string[] = [];
let eventCalls = 0;
const participant = { participantId: "viewer-opaque", role: "viewer" as const }; const participant = { participantId: "viewer-opaque", role: "viewer" as const };
const fetcher: typeof fetch = async (input, init = {}) => { const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>; 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("/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 }); return new Response(null, { status: 204 });
}; };
const media = createRemoteOfficeMedia({ const media = createRemoteOfficeMedia({
@@ -214,6 +260,8 @@ describe("remote office media presenter", () => {
}); });
const track = new FakeTrack(); 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 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(); peers[0]?.fail();
assert.equal(media.state().status, "reconnecting"); assert.equal(media.state().status, "reconnecting");
assert.equal(peers[0]?.closed, 1); assert.equal(peers[0]?.closed, 1);
@@ -221,6 +269,8 @@ describe("remote office media presenter", () => {
await settle(); await settle();
assert.equal(peers.length, 2); assert.equal(peers.length, 2);
assert.equal(peers[1]?.added[0], track as unknown as MediaStreamTrack); 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(); await media.dispose();
assert.equal(track.stopped, 0); assert.equal(track.stopped, 0);
}); });
@@ -230,6 +280,8 @@ describe("remote office media presenter", () => {
let joinRequestId = ""; let joinRequestId = "";
const fetcher: typeof fetch = async (input, init = {}) => { const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>; 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")) { if (String(input).endsWith("/join")) {
joinRequestId = String(body.requestId); joinRequestId = String(body.requestId);
return new Promise<Response>((resolve) => { resolveJoin = resolve; }); 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 () => { it("renews its lease before expiry and uses only the rotated in-memory grant", async () => {
const timers = new FakeTimers(); const timers = new FakeTimers();
const eventTokens: string[] = []; const eventTokens: string[] = [];
const iceTokens: string[] = [];
let eventCalls = 0; let eventCalls = 0;
const fetcher: typeof fetch = async (input, init = {}) => { const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>; 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")) { if (String(input).endsWith("/join")) {
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 }); 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(); await settle();
assert.deepEqual(eventTokens, ["private-presenter-grant"]); 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"); assert.equal(timers.pending.size, 1, "one pre-expiry renewal is scheduled");
timers.run(); timers.run();
await settle(); await settle();
assert.deepEqual(eventTokens, ["private-presenter-grant", "rotated-1"]); 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); assert.equal(JSON.stringify(media.state()).includes("rotated-2"), false);
await media.dispose(); 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", () => { describe("remote office media viewer", () => {
@@ -301,6 +395,8 @@ describe("remote office media viewer", () => {
const fetcher: typeof fetch = async (input, init = {}) => { const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>; const body = JSON.parse(String(init.body)) as Record<string, unknown>;
calls.push({ url: String(input), body }); 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("/join")) return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
if (String(input).endsWith("/events")) { if (String(input).endsWith("/events")) {
const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]); const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]);
@@ -348,6 +444,8 @@ describe("remote office media viewer", () => {
let leaveCalls = 0; let leaveCalls = 0;
const fetcher: typeof fetch = async (input, init = {}) => { const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>; 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")) { if (String(input).endsWith("/join")) {
return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 }); return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
} }