mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-29 14:02:31 +00:00
Add optional Cloudflare TURN credentials and secure profile sharing
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { IceServerSourceError } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { IceServerConfiguration, IceServerSource } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import {
|
||||
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
|
||||
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
|
||||
parseCloudflareIceServerSourceConfiguration,
|
||||
type CloudflareIceServerSourceConfiguration,
|
||||
validateCloudflareIceServerSourceConfiguration,
|
||||
} from "./settings";
|
||||
|
||||
/** Fetch-compatible function supplied by the host composition. */
|
||||
export type CloudflareIceServerSourceFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface CloudflareIceServerSourceDependencies {
|
||||
readonly fetch: CloudflareIceServerSourceFetch;
|
||||
readonly now?: () => number;
|
||||
readonly requestDeadlineMs?: number;
|
||||
}
|
||||
|
||||
export const CLOUDFLARE_TURN_REQUEST_DEADLINE_MS = 15_000 as const;
|
||||
export const CLOUDFLARE_TURN_MAX_RESPONSE_BYTES = 32 * 1024;
|
||||
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES = 16 as const;
|
||||
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS = 32 as const;
|
||||
export const CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS = 1_000 as const;
|
||||
|
||||
type IceServerSourceFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
|
||||
|
||||
const SOURCE_FAILURE_MESSAGES: Record<IceServerSourceFailureCode, string> = {
|
||||
configuration: "The Cloudflare TURN source configuration is invalid.",
|
||||
authentication: "The Cloudflare TURN credential request was not authorised.",
|
||||
unavailable: "The Cloudflare TURN service is unavailable.",
|
||||
"invalid-response": "The Cloudflare TURN service returned an invalid response.",
|
||||
};
|
||||
|
||||
function sourceFailure(code: IceServerSourceFailureCode, retryable: boolean): IceServerSourceError {
|
||||
return new IceServerSourceError(code, SOURCE_FAILURE_MESSAGES[code], retryable);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
try {
|
||||
return new DOMException("The operation was aborted.", "AbortError");
|
||||
} catch {
|
||||
const error = new Error("The operation was aborted.");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
function isControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 0x1f || code === 0x7f;
|
||||
});
|
||||
}
|
||||
|
||||
function isPort(value: string): boolean {
|
||||
if (!/^\d{1,5}$/.test(value)) return false;
|
||||
const port = Number(value);
|
||||
return port >= 1 && port <= 65_535;
|
||||
}
|
||||
|
||||
function isHost(value: string): boolean {
|
||||
return value.length > 0 && /^[A-Za-z0-9._-]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the URL forms accepted by WebRTC's ICE server configuration.
|
||||
* TURN URLs may carry only the standard transport query parameter; userinfo,
|
||||
* paths, fragments, and arbitrary query values are not accepted.
|
||||
*/
|
||||
export function isSupportedIceServerUrl(value: string): boolean {
|
||||
if (value.length === 0 || value.length > 2_048 || isControlCharacter(value)) return false;
|
||||
const schemeMatch = /^(stun|stuns|turn|turns):(.+)$/i.exec(value);
|
||||
if (!schemeMatch) return false;
|
||||
|
||||
const remainder = schemeMatch[2];
|
||||
const queryIndex = remainder.indexOf("?");
|
||||
const authority = queryIndex >= 0 ? remainder.slice(0, queryIndex) : remainder;
|
||||
const query = queryIndex >= 0 ? remainder.slice(queryIndex + 1) : "";
|
||||
if (authority.length === 0 || authority.includes("/") || authority.includes("#") || authority.includes("@")) {
|
||||
return false;
|
||||
}
|
||||
if (authority.includes("%")) return false;
|
||||
|
||||
if (authority.startsWith("[")) {
|
||||
const closingBracket = authority.indexOf("]");
|
||||
if (closingBracket < 0) return false;
|
||||
const host = authority.slice(1, closingBracket);
|
||||
if (!/^[0-9A-Fa-f:.]+$/.test(host) || !host.includes(":")) return false;
|
||||
const suffix = authority.slice(closingBracket + 1);
|
||||
if (suffix !== "" && (!suffix.startsWith(":") || !isPort(suffix.slice(1)))) return false;
|
||||
} else {
|
||||
const colonIndex = authority.lastIndexOf(":");
|
||||
const host = colonIndex >= 0 ? authority.slice(0, colonIndex) : authority;
|
||||
if (!isHost(host) || (colonIndex >= 0 && !isPort(authority.slice(colonIndex + 1)))) return false;
|
||||
// IPv6 literals must use brackets so a colon cannot be interpreted as
|
||||
// an ambiguous port separator.
|
||||
if (colonIndex >= 0 && host.includes(":")) return false;
|
||||
}
|
||||
|
||||
if (query.length === 0) return true;
|
||||
const queryParts = query.split("&");
|
||||
return queryParts.length === 1 && /^transport=(udp|tcp)$/i.test(queryParts[0]);
|
||||
}
|
||||
|
||||
function isTurnUrl(value: string): boolean {
|
||||
return /^(turn|turns):/i.test(value);
|
||||
}
|
||||
|
||||
function isCredential(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isControlCharacter(value);
|
||||
}
|
||||
|
||||
function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.iceServers)) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
if (value.iceServers.length === 0 || value.iceServers.length > CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
const servers: RTCIceServer[] = [];
|
||||
let urlCount = 0;
|
||||
let hasTurnServer = false;
|
||||
|
||||
for (const candidate of value.iceServers) {
|
||||
if (!isRecord(candidate)) throw sourceFailure("invalid-response", false);
|
||||
const rawUrls = candidate.urls;
|
||||
const urls =
|
||||
typeof rawUrls === "string"
|
||||
? [rawUrls]
|
||||
: Array.isArray(rawUrls) && rawUrls.every((url): url is string => typeof url === "string")
|
||||
? [...rawUrls]
|
||||
: undefined;
|
||||
if (!urls || urls.length === 0) throw sourceFailure("invalid-response", false);
|
||||
|
||||
urlCount += urls.length;
|
||||
if (urlCount > CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS || urls.some((url) => !isSupportedIceServerUrl(url))) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
const turnEntry = urls.some(isTurnUrl);
|
||||
hasTurnServer ||= turnEntry;
|
||||
const normalised: RTCIceServer = { urls };
|
||||
if (turnEntry) {
|
||||
if (!isCredential(candidate.username) || !isCredential(candidate.credential)) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
normalised.username = candidate.username;
|
||||
normalised.credential = candidate.credential;
|
||||
}
|
||||
servers.push(normalised);
|
||||
}
|
||||
|
||||
if (!hasTurnServer) throw sourceFailure("invalid-response", false);
|
||||
return Object.freeze(servers);
|
||||
}
|
||||
|
||||
class BoundedResponseError extends Error {
|
||||
constructor(readonly kind: "too-large" | "invalid-length" | "read-failed") {
|
||||
super(kind);
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponseBody(response: Response): Promise<string> {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength !== null) {
|
||||
const declaredLength = Number(contentLength);
|
||||
if (!Number.isFinite(declaredLength) || declaredLength < 0) {
|
||||
throw new BoundedResponseError("invalid-length");
|
||||
}
|
||||
if (declaredLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
|
||||
throw new BoundedResponseError("too-large");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
|
||||
throw new BoundedResponseError("too-large");
|
||||
}
|
||||
return text;
|
||||
} catch (error) {
|
||||
if (error instanceof BoundedResponseError) throw error;
|
||||
throw new BoundedResponseError("read-failed");
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
totalBytes += result.value.byteLength;
|
||||
if (totalBytes > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// The response is already invalid because it exceeded the
|
||||
// bound; cancellation failure must not change the safe
|
||||
// classification or expose a host-specific error.
|
||||
}
|
||||
throw new BoundedResponseError("too-large");
|
||||
}
|
||||
chunks.push(result.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BoundedResponseError) throw error;
|
||||
throw new BoundedResponseError("read-failed");
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function classifyHttpFailure(status: number): IceServerSourceError {
|
||||
if (status === 401 || status === 403) {
|
||||
return sourceFailure("authentication", false);
|
||||
}
|
||||
if (status === 408 || status === 429 || status >= 500) {
|
||||
return sourceFailure("unavailable", true);
|
||||
}
|
||||
return sourceFailure("unavailable", false);
|
||||
}
|
||||
|
||||
function parseResponseBody(body: string): readonly RTCIceServer[] {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
return normaliseIceServers(value);
|
||||
}
|
||||
|
||||
function createSource(
|
||||
configuration: CloudflareIceServerSourceConfiguration,
|
||||
dependencies: CloudflareIceServerSourceDependencies
|
||||
): IceServerSource {
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const requestDeadlineMs = dependencies.requestDeadlineMs ?? CLOUDFLARE_TURN_REQUEST_DEADLINE_MS;
|
||||
|
||||
return {
|
||||
async acquire(signal: AbortSignal): Promise<IceServerConfiguration> {
|
||||
throwIfAborted(signal);
|
||||
const requestStartedAt = now();
|
||||
if (!Number.isFinite(requestStartedAt)) {
|
||||
throw sourceFailure("unavailable", true);
|
||||
}
|
||||
|
||||
const requestController = new AbortController();
|
||||
let cancelledByCaller = false;
|
||||
let rejectCaller: ((reason?: unknown) => void) | undefined;
|
||||
const callerAbort = new Promise<never>((_resolve, reject) => {
|
||||
rejectCaller = reject;
|
||||
});
|
||||
let timedOut = false;
|
||||
const onAbort = () => {
|
||||
cancelledByCaller = true;
|
||||
requestController.abort();
|
||||
rejectCaller?.(abortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
requestController.abort();
|
||||
throw abortError();
|
||||
}
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
requestController.abort();
|
||||
reject(sourceFailure("unavailable", true));
|
||||
}, requestDeadlineMs);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
const endpoint = `${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/${configuration.turnKeyId}/credentials/generate-ice-servers`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await Promise.race([
|
||||
dependencies.fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${configuration.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
|
||||
signal: requestController.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
}),
|
||||
callerAbort,
|
||||
deadline,
|
||||
]);
|
||||
} catch {
|
||||
cleanup();
|
||||
if (cancelledByCaller || signal.aborted) throw abortError();
|
||||
if (timedOut) throw sourceFailure("unavailable", true);
|
||||
throw sourceFailure("unavailable", true);
|
||||
}
|
||||
|
||||
if (cancelledByCaller || signal.aborted) {
|
||||
cleanup();
|
||||
throw abortError();
|
||||
}
|
||||
if (timedOut || requestController.signal.aborted) {
|
||||
cleanup();
|
||||
throw sourceFailure("unavailable", true);
|
||||
}
|
||||
if (response.status !== 201) {
|
||||
cleanup();
|
||||
throw classifyHttpFailure(response.status);
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
body = await Promise.race([readResponseBody(response), callerAbort, deadline]);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (cancelledByCaller || signal.aborted) throw abortError();
|
||||
if (timedOut) throw sourceFailure("unavailable", true);
|
||||
if (error instanceof BoundedResponseError && error.kind === "read-failed") {
|
||||
throw sourceFailure("unavailable", true);
|
||||
}
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
const iceServers = parseResponseBody(body);
|
||||
const expiresAt = requestStartedAt + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= now() + CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS) {
|
||||
throw sourceFailure("invalid-response", false);
|
||||
}
|
||||
return { iceServers, expiresAt };
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Cloudflare source after validating its persisted configuration.
|
||||
* Validation is synchronous and performs no network request.
|
||||
*/
|
||||
export function createCloudflareIceServerSource(
|
||||
configuration: Readonly<Record<string, unknown>>,
|
||||
dependencies: CloudflareIceServerSourceDependencies
|
||||
): IceServerSource {
|
||||
const parsed = parseCloudflareIceServerSourceConfiguration(configuration);
|
||||
if (!parsed) throw sourceFailure("configuration", false);
|
||||
return createSource(parsed, dependencies);
|
||||
}
|
||||
|
||||
/** Exposes the provider validation for the integration catalogue and UI. */
|
||||
export { validateCloudflareIceServerSourceConfiguration };
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLOUDFLARE_TURN_MAX_RESPONSE_BYTES,
|
||||
CLOUDFLARE_TURN_REQUEST_DEADLINE_MS,
|
||||
createCloudflareIceServerSource,
|
||||
} from "./iceServerSource";
|
||||
import {
|
||||
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
|
||||
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
|
||||
validateCloudflareIceServerSourceConfiguration,
|
||||
} from "./settings";
|
||||
|
||||
const configuration = {
|
||||
turnKeyId: "key-123",
|
||||
apiToken: "token_abc-123",
|
||||
} as const;
|
||||
|
||||
function response(body: unknown, status = 201): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function validBody() {
|
||||
return {
|
||||
iceServers: [
|
||||
{
|
||||
urls: ["turn:relay.example.test:3478?transport=udp", "turns:relay.example.test:5349"],
|
||||
username: "turn-user",
|
||||
credential: "turn-password",
|
||||
},
|
||||
{ urls: "stun:stun.example.test:3478" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Cloudflare ICE server source", () => {
|
||||
it("requests the fixed endpoint with the bearer token and TTL", async () => {
|
||||
const now = 1_000_000;
|
||||
let requestUrl: string | Request | undefined;
|
||||
let requestInit: RequestInit | undefined;
|
||||
const fetch = vi.fn(async (input: string | Request, init?: RequestInit) => {
|
||||
requestUrl = input;
|
||||
requestInit = init;
|
||||
return response(validBody());
|
||||
});
|
||||
const source = createCloudflareIceServerSource(configuration, { fetch, now: () => now });
|
||||
|
||||
const result = await source.acquire(new AbortController().signal);
|
||||
|
||||
expect(requestUrl).toBe(`${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/key-123/credentials/generate-ice-servers`);
|
||||
expect(requestInit).toMatchObject({
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
|
||||
});
|
||||
expect(new Headers(requestInit?.headers).get("authorization")).toBe("Bearer token_abc-123");
|
||||
expect(new Headers(requestInit?.headers).get("content-type")).toBe("application/json");
|
||||
expect(requestInit?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(result.iceServers).toHaveLength(2);
|
||||
expect(result.expiresAt).toBe(now + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000);
|
||||
});
|
||||
|
||||
it("rejects malformed, oversized, and STUN-only responses without exposing secrets", async () => {
|
||||
const cases: Array<{ body: unknown; expectedCode: string }> = [
|
||||
{ body: { iceServers: [] }, expectedCode: "invalid-response" },
|
||||
{ body: { iceServers: [{ urls: "turn:relay.example.test:3478" }] }, expectedCode: "invalid-response" },
|
||||
{ body: { iceServers: [{ urls: "stun:stun.example.test:3478" }] }, expectedCode: "invalid-response" },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const source = createCloudflareIceServerSource(configuration, {
|
||||
fetch: vi.fn(async () => response(testCase.body)),
|
||||
now: () => 1_000_000,
|
||||
});
|
||||
const error = await source.acquire(new AbortController().signal).catch((reason: unknown) => reason);
|
||||
expect(error).toMatchObject({ code: testCase.expectedCode });
|
||||
expect(String(error)).not.toContain(configuration.apiToken);
|
||||
expect(String(error)).not.toContain(configuration.turnKeyId);
|
||||
}
|
||||
|
||||
const oversized = "x".repeat(CLOUDFLARE_TURN_MAX_RESPONSE_BYTES + 1);
|
||||
const source = createCloudflareIceServerSource(configuration, {
|
||||
fetch: vi.fn(async () => new Response(oversized, { status: 201 })),
|
||||
now: () => 1_000_000,
|
||||
});
|
||||
const error = await source.acquire(new AbortController().signal).catch((reason: unknown) => reason);
|
||||
expect(error).toMatchObject({ code: "invalid-response" });
|
||||
});
|
||||
|
||||
it("classifies authentication and transient provider failures", async () => {
|
||||
const authSource = createCloudflareIceServerSource(configuration, {
|
||||
fetch: vi.fn(async () => response({}, 401)),
|
||||
});
|
||||
await expect(authSource.acquire(new AbortController().signal)).rejects.toMatchObject({
|
||||
code: "authentication",
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
const transientSource = createCloudflareIceServerSource(configuration, {
|
||||
fetch: vi.fn(async () => response({}, 503)),
|
||||
});
|
||||
await expect(transientSource.acquire(new AbortController().signal)).rejects.toMatchObject({
|
||||
code: "unavailable",
|
||||
retryable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates caller cancellation and turns a deadline into an unavailable failure", async () => {
|
||||
const controller = new AbortController();
|
||||
const fetch = vi.fn((_input: string | Request, init?: RequestInit) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
const source = createCloudflareIceServerSource(configuration, { fetch });
|
||||
const cancelled = source.acquire(controller.signal);
|
||||
controller.abort();
|
||||
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
|
||||
|
||||
vi.useFakeTimers();
|
||||
const timedSource = createCloudflareIceServerSource(configuration, { fetch });
|
||||
const timed = timedSource.acquire(new AbortController().signal);
|
||||
const assertion = expect(timed).rejects.toMatchObject({ code: "unavailable", retryable: true });
|
||||
await vi.advanceTimersByTimeAsync(CLOUDFLARE_TURN_REQUEST_DEADLINE_MS);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("rejects an issuance which has no usable remaining lifetime", async () => {
|
||||
let now = 1_000_000;
|
||||
const source = createCloudflareIceServerSource(configuration, {
|
||||
fetch: vi.fn(async () => {
|
||||
now += CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
|
||||
return response(validBody());
|
||||
}),
|
||||
now: () => now,
|
||||
});
|
||||
await expect(source.acquire(new AbortController().signal)).rejects.toMatchObject({
|
||||
code: "invalid-response",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cloudflare ICE source validation", () => {
|
||||
it("rejects unknown fields and malformed bearer credentials", () => {
|
||||
expect(validateCloudflareIceServerSourceConfiguration({ ...configuration, unexpected: "value" })).toContain(
|
||||
"unsupported field"
|
||||
);
|
||||
expect(
|
||||
validateCloudflareIceServerSourceConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
|
||||
).toContain("unsupported characters");
|
||||
expect(
|
||||
validateCloudflareIceServerSourceConfiguration({ ...configuration, apiToken: "token with spaces" })
|
||||
).toContain("Bearer token syntax");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/** The source identifier persisted in a P2P profile for Cloudflare TURN. */
|
||||
export const CLOUDFLARE_ICE_SERVER_SOURCE_ID = "cloudflare" as const;
|
||||
|
||||
/** The lifetime requested from Cloudflare for each issued credential set. */
|
||||
export const CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS = 86_400 as const;
|
||||
|
||||
/** The Cloudflare TURN credential-generation endpoint. */
|
||||
export const CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT = "https://rtc.live.cloudflare.com/v1/turn/keys" as const;
|
||||
|
||||
/** A validated Cloudflare TURN source configuration. */
|
||||
export interface CloudflareIceServerSourceConfiguration {
|
||||
readonly turnKeyId: string;
|
||||
readonly apiToken: string;
|
||||
}
|
||||
|
||||
const CLOUDFLARE_CONFIGURATION_KEYS = ["turnKeyId", "apiToken"] as const;
|
||||
|
||||
// TURN Key IDs are inserted into one fixed URL path. Keep the accepted set
|
||||
// deliberately narrower than URI escaping so a configuration cannot alter
|
||||
// the request path or add a query string.
|
||||
const TURN_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
|
||||
|
||||
// RFC 6750's b64token grammar, including optional trailing padding. This
|
||||
// also excludes whitespace and control characters from the Authorization
|
||||
// header without exposing the token in a validation message.
|
||||
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/-]+={0,2}$/;
|
||||
const MAX_BEARER_TOKEN_LENGTH = 4_096;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOnlyCloudflareConfigurationKeys(value: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(value);
|
||||
return (
|
||||
keys.length === CLOUDFLARE_CONFIGURATION_KEYS.length &&
|
||||
CLOUDFLARE_CONFIGURATION_KEYS.every((key) => Object.prototype.hasOwnProperty.call(value, key))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a safe validation message for a Cloudflare source configuration.
|
||||
* The result never includes the supplied Key ID or API token.
|
||||
*/
|
||||
export function validateCloudflareIceServerSourceConfiguration(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return "Cloudflare TURN configuration is invalid.";
|
||||
}
|
||||
if (!hasOnlyCloudflareConfigurationKeys(value)) {
|
||||
return "Cloudflare TURN configuration contains an unsupported field.";
|
||||
}
|
||||
|
||||
const turnKeyId = value.turnKeyId;
|
||||
if (typeof turnKeyId !== "string" || turnKeyId.length === 0) {
|
||||
return "Enter a TURN Key ID.";
|
||||
}
|
||||
if (!TURN_KEY_ID_PATTERN.test(turnKeyId)) {
|
||||
return "TURN Key ID contains unsupported characters.";
|
||||
}
|
||||
|
||||
const apiToken = value.apiToken;
|
||||
if (typeof apiToken !== "string" || apiToken.length === 0) {
|
||||
return "Enter a TURN Key API Token.";
|
||||
}
|
||||
if (apiToken.length > MAX_BEARER_TOKEN_LENGTH || !BEARER_TOKEN_PATTERN.test(apiToken)) {
|
||||
return "TURN Key API Token must use Bearer token syntax.";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an untrusted profile value into a validated source configuration.
|
||||
* The returned object is a fresh copy so later settings mutations cannot
|
||||
* change a source which is already being used by the P2P owner.
|
||||
*/
|
||||
export function parseCloudflareIceServerSourceConfiguration(
|
||||
value: unknown
|
||||
): CloudflareIceServerSourceConfiguration | undefined {
|
||||
if (validateCloudflareIceServerSourceConfiguration(value) !== undefined || !isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
turnKeyId: value.turnKeyId as string,
|
||||
apiToken: value.apiToken as string,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CLOUDFLARE_ICE_SERVER_SOURCE_ID, validateCloudflareIceServerSourceConfiguration } from "./cloudflare/settings";
|
||||
|
||||
export const MANUAL_ICE_SERVER_SOURCE_ID = "manual" as const;
|
||||
|
||||
export type IceServerSourceSelectionId = typeof MANUAL_ICE_SERVER_SOURCE_ID | typeof CLOUDFLARE_ICE_SERVER_SOURCE_ID;
|
||||
|
||||
export interface IceServerSourceFieldDefinition {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly secret: boolean;
|
||||
}
|
||||
|
||||
export interface IceServerSourceDefinition {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly fields: readonly IceServerSourceFieldDefinition[];
|
||||
}
|
||||
|
||||
export interface IceServerSourceDescriptorLike {
|
||||
readonly version?: unknown;
|
||||
readonly id?: unknown;
|
||||
readonly configuration?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service-owned field metadata used by the P2P settings dialogue. Manual
|
||||
* TURN values remain the existing settings fields and therefore do not occur
|
||||
* in this provider catalogue.
|
||||
*/
|
||||
export const iceServerSourceDefinitions = [
|
||||
{
|
||||
id: CLOUDFLARE_ICE_SERVER_SOURCE_ID,
|
||||
label: "Cloudflare",
|
||||
fields: [
|
||||
{ key: "turnKeyId", label: "TURN Key ID", secret: false },
|
||||
{ key: "apiToken", label: "TURN Key API Token", secret: true },
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly IceServerSourceDefinition[];
|
||||
|
||||
/** The user-facing source choice, including the existing manual mode. */
|
||||
export const turnConfigurationChoices = [
|
||||
{ id: MANUAL_ICE_SERVER_SOURCE_ID, label: "Manual" },
|
||||
{ id: CLOUDFLARE_ICE_SERVER_SOURCE_ID, label: "Cloudflare" },
|
||||
] as const;
|
||||
|
||||
export const iceServerSourceChoices = turnConfigurationChoices;
|
||||
|
||||
function isRecord(value: unknown): value is IceServerSourceDescriptorLike {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a selected source descriptor without performing network access.
|
||||
* An absent descriptor represents the existing manual TURN configuration.
|
||||
*/
|
||||
export function validateIceServerSourceConfiguration(
|
||||
descriptor: IceServerSourceDescriptorLike | null | undefined
|
||||
): string | undefined {
|
||||
if (descriptor === undefined || descriptor === null) return undefined;
|
||||
if (!isRecord(descriptor)) return "TURN configuration source is invalid.";
|
||||
if (descriptor.version !== 1) return "TURN configuration source version is not supported.";
|
||||
if (descriptor.id === MANUAL_ICE_SERVER_SOURCE_ID) {
|
||||
return undefined;
|
||||
}
|
||||
if (descriptor.id !== CLOUDFLARE_ICE_SERVER_SOURCE_ID) {
|
||||
return "The selected TURN configuration source is not supported.";
|
||||
}
|
||||
return validateCloudflareIceServerSourceConfiguration(descriptor.configuration);
|
||||
}
|
||||
|
||||
export function getIceServerSourceDefinition(id: string): IceServerSourceDefinition | undefined {
|
||||
return iceServerSourceDefinitions.find((definition) => definition.id === id);
|
||||
}
|
||||
|
||||
/** Validate the selected settings projection, including an unavailable encrypted source. */
|
||||
export function validateTurnSettings(settings: {
|
||||
readonly P2P_iceServerSource?: IceServerSourceDescriptorLike | null;
|
||||
readonly encryptedP2PIceServerSource?: string;
|
||||
}): string | undefined {
|
||||
if (!settings.P2P_iceServerSource && settings.encryptedP2PIceServerSource) {
|
||||
return "TURN configuration could not be decrypted.";
|
||||
}
|
||||
return validateIceServerSourceConfiguration(settings.P2P_iceServerSource);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
iceServerSourceDefinitions,
|
||||
validateIceServerSourceConfiguration,
|
||||
validateTurnSettings,
|
||||
} from "./iceServerSources";
|
||||
|
||||
describe("ICE server source catalogue", () => {
|
||||
it("blocks an unavailable encrypted source instead of presenting manual settings as valid", () => {
|
||||
expect(validateTurnSettings({ encryptedP2PIceServerSource: "private-ciphertext" })).toBe(
|
||||
"TURN configuration could not be decrypted."
|
||||
);
|
||||
expect(validateTurnSettings({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("describes the Cloudflare fields without owning manual TURN fields", () => {
|
||||
expect(iceServerSourceDefinitions).toEqual([
|
||||
{
|
||||
id: "cloudflare",
|
||||
label: "Cloudflare",
|
||||
fields: [
|
||||
{ key: "turnKeyId", label: "TURN Key ID", secret: false },
|
||||
{ key: "apiToken", label: "TURN Key API Token", secret: true },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts absent or explicit manual selection and rejects unsupported versions", () => {
|
||||
expect(validateIceServerSourceConfiguration(undefined)).toBeUndefined();
|
||||
expect(validateIceServerSourceConfiguration({ version: 1, id: "manual" })).toBeUndefined();
|
||||
expect(validateIceServerSourceConfiguration({ version: 2, id: "cloudflare", configuration: {} })).toContain(
|
||||
"version"
|
||||
);
|
||||
expect(validateIceServerSourceConfiguration({ version: 1, id: "unknown", configuration: {} })).toContain(
|
||||
"not supported"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user