Support MSC4143 RTC Transport endpoint (#3629)

* Use rtc-focus branch of js-sdk

* Update makeTransport to fetch backend transports and validate all transports before response.

* Fix test

* Add test

* Loads more tests

* Add tests for openid errors

* improve comment

* update to develop commit

* Add JWT parsing

* Use JWT

* Cleanup

* fixup tests

* fixup tests

* lint

* lint lint

* Fix `Reconnecting`
This commit is contained in:
Will Hunt
2025-12-29 17:45:41 +00:00
committed by GitHub
parent 67d20a8f3d
commit 72ec1439f4
13 changed files with 522 additions and 131 deletions

View File

@@ -111,7 +111,7 @@
"livekit-client": "^2.13.0", "livekit-client": "^2.13.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"loglevel": "^1.9.1", "loglevel": "^1.9.1",
"matrix-js-sdk": "^39.2.0", "matrix-js-sdk": "matrix-org/matrix-js-sdk#2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3",
"matrix-widget-api": "^1.14.0", "matrix-widget-api": "^1.14.0",
"node-stdlib-browser": "^1.3.1", "node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",

View File

@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { createJTWToken } from "./fixtures/jwt-token";
test("Should show error screen if fails to get JWT token", async ({ page }) => { test("Should show error screen if fails to get JWT token", async ({ page }) => {
await page.goto("/"); await page.goto("/");
@@ -93,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
url: "wss://badurltotricktest/livekit/sfu", url: "wss://badurltotricktest/livekit/sfu",
jwt: "FAKE", jwt: createJTWToken("@fake:user", "!fake:room"),
}), }),
}), }),
); );

View File

@@ -0,0 +1,22 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
export function createJTWToken(sub: string, room: string): string {
return [
{}, // header
{
// payload
sub,
video: {
room,
},
},
{}, // signature
]
.map((d) => global.btoa(JSON.stringify(d)))
.join(".");
}

View File

@@ -68,11 +68,6 @@ test("When creator left, avoid reconnect to the same SFU", async ({
reducedMotion: "reduce", reducedMotion: "reduce",
}); });
const guestCPage = await guestC.newPage(); const guestCPage = await guestC.newPage();
let sfuGetCallCount = 0;
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
sfuGetCallCount++;
await route.continue();
});
// Track WebSocket connections // Track WebSocket connections
let wsConnectionCount = 0; let wsConnectionCount = 0;
await guestCPage.routeWebSocket("**", (ws) => { await guestCPage.routeWebSocket("**", (ws) => {
@@ -100,5 +95,4 @@ test("When creator left, avoid reconnect to the same SFU", async ({
// https://github.com/element-hq/element-call/issues/3344 // https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU // The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1); expect(wsConnectionCount).toBe(1);
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
}); });

View File

@@ -0,0 +1,112 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
beforeEach,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures";
const sfuUrl = "https://sfu.example.org";
describe("getSFUConfigWithOpenID", () => {
let matrixClient: MockedObject<OpenIDClientParts>;
beforeEach(() => {
matrixClient = {
getOpenIdToken: vitest.fn(),
getDeviceId: vitest.fn(),
};
});
afterEach(() => {
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
"https://sfu.example.org",
"!example_room_id",
);
} catch (ex) {
expect(((ex as Error).cause as Error).message).toEqual(
"SFU Config fetch failed with status code 500",
);
void (await fetchMock.flush());
return;
}
expect.fail("Expected test to throw;");
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
});

View File

@@ -11,9 +11,47 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { FailToGetOpenIdToken } from "../utils/errors"; import { FailToGetOpenIdToken } from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix"; import { doNetworkOperationWithRetry } from "../utils/matrix";
/**
* Configuration and access tokens provided by the SFU on successful authentication.
*/
export interface SFUConfig { export interface SFUConfig {
url: string; url: string;
jwt: string; jwt: string;
livekitAlias: string;
livekitIdentity: string;
}
/**
* Decoded details from the JWT.
*/
interface SFUJWTPayload {
/**
* Expiration time for the JWT.
* Note: This value is in seconds since Unix epoch.
*/
exp: number;
/**
* Name of the instance which authored the JWT
*/
iss: string;
/**
* Time at which the JWT can start to be used.
* Note: This value is in seconds since Unix epoch.
*/
nbf: number;
/**
* Subject. The Livekit alias in this context.
*/
sub: string;
/**
* The set of permissions for the user.
*/
video: {
canPublish: boolean;
canSubscribe: boolean;
room: string;
roomJoin: boolean;
};
} }
// The bits we need from MatrixClient // The bits we need from MatrixClient
@@ -57,7 +95,17 @@ export async function getSFUConfigWithOpenID(
); );
logger.info(`Got JWT from call's active focus URL.`); logger.info(`Got JWT from call's active focus URL.`);
return sfuConfig; // Pull the details from the JWT
const [, payloadStr] = sfuConfig.jwt.split(".");
// TODO: Prefer Uint8Array.fromBase64 when widely available
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
// NOTE: Currently unused.
livekitIdentity: payload.sub,
};
} }
async function getLiveKitJWT( async function getLiveKitJWT(
@@ -65,7 +113,7 @@ async function getLiveKitJWT(
livekitServiceURL: string, livekitServiceURL: string,
roomName: string, roomName: string,
openIDToken: IOpenIDToken, openIDToken: IOpenIDToken,
): Promise<SFUConfig> { ): Promise<{ url: string; jwt: string }> {
try { try {
const res = await fetch(livekitServiceURL + "/sfu/get", { const res = await fetch(livekitServiceURL + "/sfu/get", {
method: "POST", method: "POST",
@@ -83,6 +131,6 @@ async function getLiveKitJWT(
} }
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
throw new Error("SFU Config fetch failed with exception " + e); throw new Error("SFU Config fetch failed with exception", { cause: e });
} }
} }

View File

@@ -1256,7 +1256,9 @@ describe.each([
rtcSession.membershipStatus = Status.Connected; rtcSession.membershipStatus = Status.Connected;
}, },
n: () => { n: () => {
rtcSession.membershipStatus = Status.Reconnecting; // NOTE: This was removed in https://github.com/matrix-org/matrix-js-sdk/pull/5103 accidentally.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rtcSession.membershipStatus = "Reconnecting" as any;
}, },
}); });
schedule(probablyLeftMarbles, { schedule(probablyLeftMarbles, {

View File

@@ -5,9 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import {
afterEach,
beforeEach,
describe,
expect,
it,
type MockedObject,
vi,
} from "vitest";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock";
import { mockConfig, flushPromises } from "../../../utils/test"; import { mockConfig, flushPromises } from "../../../utils/test";
import { createLocalTransport$ } from "./LocalTransport"; import { createLocalTransport$ } from "./LocalTransport";
@@ -18,31 +27,22 @@ import {
FailToGetOpenIdToken, FailToGetOpenIdToken,
} from "../../../utils/errors"; } from "../../../utils/errors";
import * as openIDSFU from "../../../livekit/openIDSFU"; import * as openIDSFU from "../../../livekit/openIDSFU";
import { customLivekitUrl } from "../../../settings/settings";
import { testJWTToken } from "../../../utils/test-fixtures";
describe("LocalTransport", () => { describe("LocalTransport", () => {
const openIdResponse: openIDSFU.SFUConfig = {
url: "https://lk.example.org",
jwt: testJWTToken,
livekitAlias: "!example_room_id",
livekitIdentity: "@lk_user:ABCDEF",
};
let scope: ObservableScope; let scope: ObservableScope;
beforeEach(() => (scope = new ObservableScope())); beforeEach(() => {
afterEach(() => scope.end()); scope = new ObservableScope();
it("throws if config is missing", async () => {
const localTransport$ = createLocalTransport$({
scope,
roomId: "!room:example.org",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
await flushPromises();
expect(() => localTransport$.value).toThrow(
new MatrixRTCTransportMissingError(""),
);
}); });
afterEach(() => scope.end());
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => { it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport // Provide a valid config so makeTransportInternal resolves a transport
@@ -61,12 +61,14 @@ describe("LocalTransport", () => {
const errors: Error[] = []; const errors: Error[] = [];
const localTransport$ = createLocalTransport$({ const localTransport$ = createLocalTransport$({
scope, scope,
roomId: "!room:example.org", roomId: "!example_room_id",
useOldestMember$: constant(false), useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
// Use empty domain to skip .well-known and use config directly // Use empty domain to skip .well-known and use config directly
getDomain: () => "", getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(), getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
@@ -84,41 +86,6 @@ describe("LocalTransport", () => {
expect(() => localTransport$.value).toThrow(expectedError); expect(() => localTransport$.value).toThrow(expectedError);
}); });
it("emits preferred transport after OpenID resolves", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
const localTransport$ = createLocalTransport$({
scope,
roomId: "!room:example.org",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
openIdResolver.resolve?.({ url: "https://lk.example.org", jwt: "jwt" });
expect(localTransport$.value).toBe(null);
await flushPromises();
// final
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!room:example.org",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
});
it("updates local transport when oldest member changes", async () => { it("updates local transport when oldest member changes", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch // Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({ mockConfig({
@@ -133,24 +100,171 @@ describe("LocalTransport", () => {
const localTransport$ = createLocalTransport$({ const localTransport$ = createLocalTransport$({
scope, scope,
roomId: "!room:example.org", roomId: "!example_room_id",
useOldestMember$: constant(true), useOldestMember$: constant(true),
memberships$, memberships$,
client: { client: {
getDomain: () => "", getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(), getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
}); });
openIdResolver.resolve?.({ url: "https://lk.example.org", jwt: "jwt" }); openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null); expect(localTransport$.value).toBe(null);
await flushPromises(); await flushPromises();
// final // final
expect(localTransport$.value).toStrictEqual({ expect(localTransport$.value).toStrictEqual({
livekit_alias: "!room:example.org", livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
type: "livekit", type: "livekit",
}); });
}); });
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
describe("transport configuration mechanisms", () => {
let localTransportOpts: LocalTransportProps & {
client: MockedObject<LocalTransportProps["client"]>;
};
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
beforeEach(() => {
mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
localTransportOpts = {
scope,
roomId: "!example_room_id",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: vi.fn().mockReturnValue(""),
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
};
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
});
afterEach(() => {
fetchMock.reset();
});
it("supports getting transport via application config", async () => {
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
const localTransport$ = createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null);
await flushPromises();
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
});
it("supports getting transport via user settings", async () => {
customLivekitUrl.setValue("https://lk.example.org");
const localTransport$ = createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null);
await flushPromises();
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
});
it("supports getting transport via backend", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
const localTransport$ = createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null);
await flushPromises();
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
});
it("fails fast if the openID request fails for backend config", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
try {
await lastValueFrom(createLocalTransport$(localTransportOpts));
throw Error("Expected test to throw");
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
}
});
it("supports getting transport via well-known", async () => {
localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
"org.matrix.msc4143.rtc_foci": [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
});
const localTransport$ = createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(localTransport$.value).toBe(null);
await flushPromises();
expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id",
livekit_service_url: "https://lk.example.org",
type: "livekit",
});
expect(fetchMock.done()).toEqual(true);
});
it("fails fast if the openId request fails for the well-known config", async () => {
localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
"org.matrix.msc4143.rtc_foci": [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
});
openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
try {
await lastValueFrom(createLocalTransport$(localTransportOpts));
throw Error("Expected test to throw");
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
}
});
it("throws if no options are available", async () => {
const localTransport$ = createLocalTransport$({
scope,
roomId: "!example_room_id",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
await flushPromises();
expect(() => localTransport$.value).toThrow(
new MatrixRTCTransportMissingError(""),
);
});
});
}); });

View File

@@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details.
import { import {
type CallMembership, type CallMembership,
isLivekitTransport, isLivekitTransport,
type LivekitTransportConfig,
type LivekitTransport, type LivekitTransport,
isLivekitTransportConfig, isLivekitTransportConfig,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk"; import { MatrixError, type MatrixClient } from "matrix-js-sdk";
import { import {
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
@@ -27,7 +27,10 @@ import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts"; import {
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
} from "../../../utils/errors.ts";
import { import {
getSFUConfigWithOpenID, getSFUConfigWithOpenID,
type OpenIDClientParts, type OpenIDClientParts,
@@ -45,7 +48,8 @@ const logger = rootLogger.getChild("[LocalTransport]");
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
memberships$: Behavior<Epoch<CallMembership[]>>; memberships$: Behavior<Epoch<CallMembership[]>>;
client: Pick<MatrixClient, "getDomain"> & OpenIDClientParts; client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
OpenIDClientParts;
roomId: string; roomId: string;
useOldestMember$: Behavior<boolean>; useOldestMember$: Behavior<boolean>;
} }
@@ -116,73 +120,150 @@ export const createLocalTransport$ = ({
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
/** /**
* Determine the correct Transport for the current session, including
* validating auth against the service to ensure it's correct.
* Prefers in order:
* *
* @param client * 1. The `urlFromDevSettings` value. If this cannot be validated, the function will throw.
* @param roomId * 2. The transports returned via the homeserver.
* @returns * 3. The transports returned via .well-known.
* 4. The transport configured in Element Call's config.
*
* @param client The authenticated Matrix client for the current user
* @param roomId The ID of the room to be connected to.
* @param urlFromDevSettings Override URL provided by the user's local config.
* @returns A fully validated transport config.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
async function makeTransport( async function makeTransport(
client: Pick<MatrixClient, "getDomain"> & OpenIDClientParts, client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
OpenIDClientParts,
roomId: string, roomId: string,
urlFromDevSettings: string | null, urlFromDevSettings: string | null,
): Promise<LivekitTransport> { ): Promise<LivekitTransport> {
let transport: LivekitTransport | undefined;
logger.trace("Searching for a preferred transport"); logger.trace("Searching for a preferred transport");
//TODO refactor this to use the jwt service returned alias.
const livekitAlias = roomId; // We will call `getSFUConfigWithOpenID` once per transport here as it's our
// only mechanism of valiation. This means we will also ask the
// homeserver for a OpenID token a few times. Since OpenID tokens are single
// use we don't want to risk any issues by re-using a token.
//
// If the OpenID request were to fail then it's acceptable for us to fail
// this function early, as we assume the homeserver has got some problems.
// DEVTOOL: Highest priority: Load from devtool setting // DEVTOOL: Highest priority: Load from devtool setting
if (urlFromDevSettings !== null) { if (urlFromDevSettings !== null) {
const transportFromStorage: LivekitTransport = { logger.info("Using LiveKit transport from dev tools: ", urlFromDevSettings);
// Validate that the SFU is up. Otherwise, we want to fail on this
// as we don't permit other SFUs.
const config = await getSFUConfigWithOpenID(
client,
urlFromDevSettings,
roomId,
);
return {
type: "livekit", type: "livekit",
livekit_service_url: urlFromDevSettings, livekit_service_url: urlFromDevSettings,
livekit_alias: livekitAlias, livekit_alias: config.livekitAlias,
}; };
logger.info(
"Using LiveKit transport from dev tools: ",
transportFromStorage,
);
transport = transportFromStorage;
} }
// WELL_KNOWN: Prioritize the .well-known/matrix/client, if available, over the configured SFU async function getFirstUsableTransport(
transports: Transport[],
): Promise<LivekitTransport | null> {
for (const potentialTransport of transports) {
if (isLivekitTransportConfig(potentialTransport)) {
try {
const { livekitAlias } = await getSFUConfigWithOpenID(
client,
potentialTransport.livekit_service_url,
roomId,
);
return {
...potentialTransport,
livekit_alias: livekitAlias,
};
} catch (ex) {
if (ex instanceof FailToGetOpenIdToken) {
// Explictly throw these
throw ex;
}
logger.debug(
`Could not use SFU service "${potentialTransport.livekit_service_url}" as SFU`,
ex,
);
}
}
}
return null;
}
// MSC4143: Attempt to fetch transports from backend.
if ("_unstable_getRTCTransports" in client) {
try {
const selectedTransport = await getFirstUsableTransport(
await client._unstable_getRTCTransports(),
);
if (selectedTransport) {
logger.info("Using backend-configured SFU", selectedTransport);
return selectedTransport;
}
} catch (ex) {
if (ex instanceof MatrixError && ex.httpStatus === 404) {
// Expected, this is an unstable endpoint and it's not required.
logger.debug("Backend does not provide any RTC transports", ex);
} else if (ex instanceof FailToGetOpenIdToken) {
throw ex;
} else {
// We got an error that wasn't just missing support for the feature, so log it loudly.
logger.error(
"Unexpected error fetching RTC transports from backend",
ex,
);
}
}
}
// Legacy MSC4143 (to be removed) WELL_KNOWN: Prioritize the .well-known/matrix/client, if available.
const domain = client.getDomain(); const domain = client.getDomain();
if (domain && transport === undefined) { if (domain) {
// we use AutoDiscovery instead of relying on the MatrixClient having already // we use AutoDiscovery instead of relying on the MatrixClient having already
// been fully configured and started // been fully configured and started
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[ const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
FOCI_WK_KEY FOCI_WK_KEY
]; ];
if (Array.isArray(wellKnownFoci)) { const selectedTransport = Array.isArray(wellKnownFoci)
const wellKnownTransport: LivekitTransportConfig | undefined = ? await getFirstUsableTransport(wellKnownFoci)
wellKnownFoci.find((f) => f && isLivekitTransportConfig(f)); : null;
if (wellKnownTransport !== undefined) { if (selectedTransport) {
logger.info("Using LiveKit transport from .well-known: ", transport); logger.info("Using .well-known SFU", selectedTransport);
transport = { ...wellKnownTransport, livekit_alias: livekitAlias }; return selectedTransport;
}
} }
} }
// CONFIG: Least prioritized; Load from config file // CONFIG: Least prioritized; Load from config file
const urlFromConf = Config.get().livekit?.livekit_service_url; const urlFromConf = Config.get().livekit?.livekit_service_url;
if (urlFromConf && transport === undefined) { if (urlFromConf) {
const transportFromConf: LivekitTransport = { try {
type: "livekit", const { livekitAlias } = await getSFUConfigWithOpenID(
livekit_service_url: urlFromConf, client,
livekit_alias: livekitAlias, urlFromConf,
}; roomId,
logger.info("Using LiveKit transport from config: ", transportFromConf); );
transport = transportFromConf; const selectedTransport: LivekitTransport = {
type: "livekit",
livekit_service_url: urlFromConf,
livekit_alias: livekitAlias,
};
logger.info("Using config SFU", selectedTransport);
return selectedTransport;
} catch (ex) {
if (ex instanceof FailToGetOpenIdToken) {
throw ex;
}
logger.error("Failed to validate config SFU", ex);
}
} }
if (!transport) throw new MatrixRTCTransportMissingError(domain ?? ""); // this will call the jwt/sfu/get endpoint to pre create the livekit room. throw new MatrixRTCTransportMissingError(domain ?? "");
await getSFUConfigWithOpenID(
client,
transport.livekit_service_url,
transport.livekit_alias,
);
return transport;
} }

View File

@@ -39,6 +39,7 @@ import {
ElementCallError, ElementCallError,
FailToGetOpenIdToken, FailToGetOpenIdToken,
} from "../../../utils/errors.ts"; } from "../../../utils/errors.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts";
import { mockRemoteParticipant } from "../../../utils/test.ts"; import { mockRemoteParticipant } from "../../../utils/test.ts";
let testScope: ObservableScope; let testScope: ObservableScope;
@@ -121,7 +122,7 @@ function setupRemoteConnection(): Connection {
status: 200, status: 200,
body: { body: {
url: "wss://matrix-rtc.m.localhost/livekit/sfu", url: "wss://matrix-rtc.m.localhost/livekit/sfu",
jwt: "ATOKEN", jwt: testJWTToken,
}, },
}; };
}); });
@@ -258,7 +259,7 @@ describe("Start connection states", () => {
capturedState.cause instanceof Error capturedState.cause instanceof Error
) { ) {
expect(capturedState.cause.message).toContain( expect(capturedState.cause.message).toContain(
"SFU Config fetch failed with exception Error", "SFU Config fetch failed with exception",
); );
expect(connection.transport.livekit_alias).toEqual( expect(connection.transport.livekit_alias).toEqual(
livekitFocus.livekit_alias, livekitFocus.livekit_alias,
@@ -294,7 +295,7 @@ describe("Start connection states", () => {
status: 200, status: 200,
body: { body: {
url: "wss://matrix-rtc.m.localhost/livekit/sfu", url: "wss://matrix-rtc.m.localhost/livekit/sfu",
jwt: "ATOKEN", jwt: testJWTToken,
}, },
}; };
}); });

View File

@@ -33,6 +33,7 @@ import {
} from "./MatrixLivekitMembers.ts"; } from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts"; import { createConnectionManager$ } from "./ConnectionManager.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger // Test the integration of ConnectionManager and MatrixLivekitMerger
@@ -85,7 +86,7 @@ beforeEach(() => {
status: 200, status: 200,
body: { body: {
url: `wss://${domain}/livekit/sfu`, url: `wss://${domain}/livekit/sfu`,
jwt: "ATOKEN", jwt: testJWTToken,
}, },
}; };
}); });

View File

@@ -59,3 +59,17 @@ export const daveRTLRtcMember = mockRtcMembership("@dave2:example.org", "DDDD");
export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, { export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD", rawDisplayName: "\u202eevaD",
}); });
export const testJWTToken = [
{}, // header
{
// payload
sub: "@me:example.org:ABCDEF",
video: {
room: "!example_room_id",
},
},
{}, // signature
]
.map((d) => global.btoa(JSON.stringify(d)))
.join(".");

View File

@@ -2802,10 +2802,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.3.0": "@matrix-org/matrix-sdk-crypto-wasm@npm:^16.0.0":
version: 15.3.0 version: 16.0.0
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.3.0" resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:16.0.0"
checksum: 10c0/45628f36b7b0e54a8777ae67a7233dbdf3e3cf14e0d95d21f62f89a7ea7e3f907232f1eb7b1262193b1e227759fad47af829dcccc103ded89011f13c66f01d76 checksum: 10c0/13b4ede3e618da819957abff778afefcf3baf9a2faac04a36bb5a07a44fae2ea05fbfa072eb3408d48b2b7b9aaf27242ce52c594c8ce9bf1fb8b3aade2832be1
languageName: node languageName: node
linkType: hard linkType: hard
@@ -7841,7 +7841,7 @@ __metadata:
livekit-client: "npm:^2.13.0" livekit-client: "npm:^2.13.0"
lodash-es: "npm:^4.17.21" lodash-es: "npm:^4.17.21"
loglevel: "npm:^1.9.1" loglevel: "npm:^1.9.1"
matrix-js-sdk: "npm:^39.2.0" matrix-js-sdk: "matrix-org/matrix-js-sdk#2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3"
matrix-widget-api: "npm:^1.14.0" matrix-widget-api: "npm:^1.14.0"
node-stdlib-browser: "npm:^1.3.1" node-stdlib-browser: "npm:^1.3.1"
normalize.css: "npm:^8.0.1" normalize.css: "npm:^8.0.1"
@@ -10780,12 +10780,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"matrix-js-sdk@npm:^39.2.0": "matrix-js-sdk@matrix-org/matrix-js-sdk#2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3":
version: 39.3.0 version: 39.3.0
resolution: "matrix-js-sdk@npm:39.3.0" resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3"
dependencies: dependencies:
"@babel/runtime": "npm:^7.12.5" "@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.3.0" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^16.0.0"
another-json: "npm:^0.2.0" another-json: "npm:^0.2.0"
bs58: "npm:^6.0.0" bs58: "npm:^6.0.0"
content-type: "npm:^1.0.4" content-type: "npm:^1.0.4"
@@ -10798,7 +10798,7 @@ __metadata:
sdp-transform: "npm:^3.0.0" sdp-transform: "npm:^3.0.0"
unhomoglyph: "npm:^1.0.6" unhomoglyph: "npm:^1.0.6"
uuid: "npm:13" uuid: "npm:13"
checksum: 10c0/031c9ec042e00c32dc531f82fc59c64cc25fb665abfc642b1f0765c530d60684f8bd63daf0cdd0dbe96b4f87ea3f4148f9d3f024a59d57eceaec1ce5d0164755 checksum: 10c0/feca51c7ada5a56aa6cfb74f29bd1640a20804e9de689d23f10c5227e07ba4f66ebbb9606e1384390dca277a6942886706198394717694a9cfb1f20cd36ca377
languageName: node languageName: node
linkType: hard linkType: hard