From 72ec1439f41770dc89815c728081de2926c69287 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:45:41 +0000 Subject: [PATCH] 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` --- package.json | 2 +- playwright/errors.spec.ts | 4 +- playwright/fixtures/jwt-token.ts | 22 ++ playwright/sfu-reconnect-bug.spec.ts | 6 - src/livekit/openIDSFU.test.ts | 112 +++++++++ src/livekit/openIDSFU.ts | 54 +++- src/state/CallViewModel/CallViewModel.test.ts | 4 +- .../localMember/LocalTransport.test.ts | 238 +++++++++++++----- .../localMember/LocalTransport.ts | 169 +++++++++---- .../remoteMembers/Connection.test.ts | 7 +- .../remoteMembers/integration.test.ts | 3 +- src/utils/test-fixtures.ts | 14 ++ yarn.lock | 18 +- 13 files changed, 522 insertions(+), 131 deletions(-) create mode 100644 playwright/fixtures/jwt-token.ts create mode 100644 src/livekit/openIDSFU.test.ts diff --git a/package.json b/package.json index 53538928..c67c2e4c 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "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", "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", diff --git a/playwright/errors.spec.ts b/playwright/errors.spec.ts index 0d36f7ab..085fb0b4 100644 --- a/playwright/errors.spec.ts +++ b/playwright/errors.spec.ts @@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details. 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 }) => { await page.goto("/"); @@ -93,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({ contentType: "application/json", body: JSON.stringify({ url: "wss://badurltotricktest/livekit/sfu", - jwt: "FAKE", + jwt: createJTWToken("@fake:user", "!fake:room"), }), }), ); diff --git a/playwright/fixtures/jwt-token.ts b/playwright/fixtures/jwt-token.ts new file mode 100644 index 00000000..18119c7e --- /dev/null +++ b/playwright/fixtures/jwt-token.ts @@ -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("."); +} diff --git a/playwright/sfu-reconnect-bug.spec.ts b/playwright/sfu-reconnect-bug.spec.ts index 6138eb78..9f666f0f 100644 --- a/playwright/sfu-reconnect-bug.spec.ts +++ b/playwright/sfu-reconnect-bug.spec.ts @@ -68,11 +68,6 @@ test("When creator left, avoid reconnect to the same SFU", async ({ reducedMotion: "reduce", }); const guestCPage = await guestC.newPage(); - let sfuGetCallCount = 0; - await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => { - sfuGetCallCount++; - await route.continue(); - }); // Track WebSocket connections let wsConnectionCount = 0; 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 // The app used to request a new jwt token then to reconnect to the SFU expect(wsConnectionCount).toBe(1); - expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */); }); diff --git a/src/livekit/openIDSFU.test.ts b/src/livekit/openIDSFU.test.ts new file mode 100644 index 00000000..2a260b01 --- /dev/null +++ b/src/livekit/openIDSFU.test.ts @@ -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; + 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()); + }); +}); diff --git a/src/livekit/openIDSFU.ts b/src/livekit/openIDSFU.ts index 3ae003fb..b3c07397 100644 --- a/src/livekit/openIDSFU.ts +++ b/src/livekit/openIDSFU.ts @@ -11,9 +11,47 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { FailToGetOpenIdToken } from "../utils/errors"; import { doNetworkOperationWithRetry } from "../utils/matrix"; +/** + * Configuration and access tokens provided by the SFU on successful authentication. + */ export interface SFUConfig { url: 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 @@ -57,7 +95,17 @@ export async function getSFUConfigWithOpenID( ); 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( @@ -65,7 +113,7 @@ async function getLiveKitJWT( livekitServiceURL: string, roomName: string, openIDToken: IOpenIDToken, -): Promise { +): Promise<{ url: string; jwt: string }> { try { const res = await fetch(livekitServiceURL + "/sfu/get", { method: "POST", @@ -83,6 +131,6 @@ async function getLiveKitJWT( } return await res.json(); } catch (e) { - throw new Error("SFU Config fetch failed with exception " + e); + throw new Error("SFU Config fetch failed with exception", { cause: e }); } } diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 86cde12a..3205c07f 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -1256,7 +1256,9 @@ describe.each([ rtcSession.membershipStatus = Status.Connected; }, 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, { diff --git a/src/state/CallViewModel/localMember/LocalTransport.test.ts b/src/state/CallViewModel/localMember/LocalTransport.test.ts index c1c36fa5..3e69bf2c 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.test.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.test.ts @@ -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. */ -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 { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, lastValueFrom } from "rxjs"; +import fetchMock from "fetch-mock"; import { mockConfig, flushPromises } from "../../../utils/test"; import { createLocalTransport$ } from "./LocalTransport"; @@ -18,31 +27,22 @@ import { FailToGetOpenIdToken, } from "../../../utils/errors"; import * as openIDSFU from "../../../livekit/openIDSFU"; +import { customLivekitUrl } from "../../../settings/settings"; +import { testJWTToken } from "../../../utils/test-fixtures"; describe("LocalTransport", () => { + const openIdResponse: openIDSFU.SFUConfig = { + url: "https://lk.example.org", + jwt: testJWTToken, + livekitAlias: "!example_room_id", + livekitIdentity: "@lk_user:ABCDEF", + }; + let scope: ObservableScope; - beforeEach(() => (scope = new ObservableScope())); - afterEach(() => scope.end()); - - it("throws if config is missing", async () => { - const localTransport$ = createLocalTransport$({ - scope, - roomId: "!room:example.org", - useOldestMember$: constant(false), - memberships$: constant(new Epoch([])), - 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(""), - ); + beforeEach(() => { + scope = new ObservableScope(); }); + afterEach(() => scope.end()); it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => { // Provide a valid config so makeTransportInternal resolves a transport @@ -61,12 +61,14 @@ describe("LocalTransport", () => { const errors: Error[] = []; const localTransport$ = createLocalTransport$({ scope, - roomId: "!room:example.org", + roomId: "!example_room_id", useOldestMember$: constant(false), memberships$: constant(new Epoch([])), client: { // Use empty domain to skip .well-known and use config directly getDomain: () => "", + // eslint-disable-next-line @typescript-eslint/naming-convention + _unstable_getRTCTransports: async () => Promise.resolve([]), getOpenIdToken: vi.fn(), getDeviceId: vi.fn(), }, @@ -84,41 +86,6 @@ describe("LocalTransport", () => { 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(); - - vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue( - openIdResolver.promise, - ); - - const localTransport$ = createLocalTransport$({ - scope, - roomId: "!room:example.org", - useOldestMember$: constant(false), - memberships$: constant(new Epoch([])), - 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 () => { // Use config so transport discovery succeeds, but delay OpenID JWT fetch mockConfig({ @@ -133,24 +100,171 @@ describe("LocalTransport", () => { const localTransport$ = createLocalTransport$({ scope, - roomId: "!room:example.org", + roomId: "!example_room_id", useOldestMember$: constant(true), memberships$, client: { getDomain: () => "", + // eslint-disable-next-line @typescript-eslint/naming-convention + _unstable_getRTCTransports: async () => Promise.resolve([]), getOpenIdToken: vi.fn(), getDeviceId: vi.fn(), }, }); - openIdResolver.resolve?.({ url: "https://lk.example.org", jwt: "jwt" }); + openIdResolver.resolve?.(openIdResponse); expect(localTransport$.value).toBe(null); await flushPromises(); // final expect(localTransport$.value).toStrictEqual({ - livekit_alias: "!room:example.org", + livekit_alias: "!example_room_id", livekit_service_url: "https://lk.example.org", type: "livekit", }); }); + + type LocalTransportProps = Parameters[0]; + + describe("transport configuration mechanisms", () => { + let localTransportOpts: LocalTransportProps & { + client: MockedObject; + }; + let openIdResolver: PromiseWithResolvers; + beforeEach(() => { + mockConfig({}); + customLivekitUrl.setValue(customLivekitUrl.defaultValue); + localTransportOpts = { + scope, + roomId: "!example_room_id", + useOldestMember$: constant(false), + memberships$: constant(new Epoch([])), + 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(); + 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([])), + 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(""), + ); + }); + }); }); diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 1320b8c4..e72b076f 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details. import { type CallMembership, isLivekitTransport, - type LivekitTransportConfig, type LivekitTransport, isLivekitTransportConfig, + type Transport, } from "matrix-js-sdk/lib/matrixrtc"; -import { type MatrixClient } from "matrix-js-sdk"; +import { MatrixError, type MatrixClient } from "matrix-js-sdk"; import { combineLatest, distinctUntilChanged, @@ -27,7 +27,10 @@ import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { type Behavior } from "../../Behavior.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts"; import { Config } from "../../../config/Config.ts"; -import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts"; +import { + FailToGetOpenIdToken, + MatrixRTCTransportMissingError, +} from "../../../utils/errors.ts"; import { getSFUConfigWithOpenID, type OpenIDClientParts, @@ -45,7 +48,8 @@ const logger = rootLogger.getChild("[LocalTransport]"); interface Props { scope: ObservableScope; memberships$: Behavior>; - client: Pick & OpenIDClientParts; + client: Pick & + OpenIDClientParts; roomId: string; useOldestMember$: Behavior; } @@ -116,73 +120,150 @@ export const createLocalTransport$ = ({ 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 - * @param roomId - * @returns + * 1. The `urlFromDevSettings` value. If this cannot be validated, the function will throw. + * 2. The transports returned via the homeserver. + * 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 */ async function makeTransport( - client: Pick & OpenIDClientParts, + client: Pick & + OpenIDClientParts, roomId: string, urlFromDevSettings: string | null, ): Promise { - let transport: LivekitTransport | undefined; 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 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", 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 { + 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(); - if (domain && transport === undefined) { + if (domain) { // we use AutoDiscovery instead of relying on the MatrixClient having already // been fully configured and started const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[ FOCI_WK_KEY ]; - if (Array.isArray(wellKnownFoci)) { - const wellKnownTransport: LivekitTransportConfig | undefined = - wellKnownFoci.find((f) => f && isLivekitTransportConfig(f)); - if (wellKnownTransport !== undefined) { - logger.info("Using LiveKit transport from .well-known: ", transport); - transport = { ...wellKnownTransport, livekit_alias: livekitAlias }; - } + const selectedTransport = Array.isArray(wellKnownFoci) + ? await getFirstUsableTransport(wellKnownFoci) + : null; + if (selectedTransport) { + logger.info("Using .well-known SFU", selectedTransport); + return selectedTransport; } } // CONFIG: Least prioritized; Load from config file const urlFromConf = Config.get().livekit?.livekit_service_url; - if (urlFromConf && transport === undefined) { - const transportFromConf: LivekitTransport = { - type: "livekit", - livekit_service_url: urlFromConf, - livekit_alias: livekitAlias, - }; - logger.info("Using LiveKit transport from config: ", transportFromConf); - transport = transportFromConf; + if (urlFromConf) { + try { + const { livekitAlias } = await getSFUConfigWithOpenID( + client, + urlFromConf, + roomId, + ); + 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. - - await getSFUConfigWithOpenID( - client, - transport.livekit_service_url, - transport.livekit_alias, - ); - - return transport; + throw new MatrixRTCTransportMissingError(domain ?? ""); } diff --git a/src/state/CallViewModel/remoteMembers/Connection.test.ts b/src/state/CallViewModel/remoteMembers/Connection.test.ts index bcc0bac2..c1e24eb4 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.test.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.test.ts @@ -39,6 +39,7 @@ import { ElementCallError, FailToGetOpenIdToken, } from "../../../utils/errors.ts"; +import { testJWTToken } from "../../../utils/test-fixtures.ts"; import { mockRemoteParticipant } from "../../../utils/test.ts"; let testScope: ObservableScope; @@ -121,7 +122,7 @@ function setupRemoteConnection(): Connection { status: 200, body: { url: "wss://matrix-rtc.m.localhost/livekit/sfu", - jwt: "ATOKEN", + jwt: testJWTToken, }, }; }); @@ -258,7 +259,7 @@ describe("Start connection states", () => { capturedState.cause instanceof Error ) { expect(capturedState.cause.message).toContain( - "SFU Config fetch failed with exception Error", + "SFU Config fetch failed with exception", ); expect(connection.transport.livekit_alias).toEqual( livekitFocus.livekit_alias, @@ -294,7 +295,7 @@ describe("Start connection states", () => { status: 200, body: { url: "wss://matrix-rtc.m.localhost/livekit/sfu", - jwt: "ATOKEN", + jwt: testJWTToken, }, }; }); diff --git a/src/state/CallViewModel/remoteMembers/integration.test.ts b/src/state/CallViewModel/remoteMembers/integration.test.ts index 6108c7bc..d885ddc6 100644 --- a/src/state/CallViewModel/remoteMembers/integration.test.ts +++ b/src/state/CallViewModel/remoteMembers/integration.test.ts @@ -33,6 +33,7 @@ import { } from "./MatrixLivekitMembers.ts"; import { createConnectionManager$ } from "./ConnectionManager.ts"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; +import { testJWTToken } from "../../../utils/test-fixtures.ts"; // Test the integration of ConnectionManager and MatrixLivekitMerger @@ -85,7 +86,7 @@ beforeEach(() => { status: 200, body: { url: `wss://${domain}/livekit/sfu`, - jwt: "ATOKEN", + jwt: testJWTToken, }, }; }); diff --git a/src/utils/test-fixtures.ts b/src/utils/test-fixtures.ts index 4cf330b7..f915bb19 100644 --- a/src/utils/test-fixtures.ts +++ b/src/utils/test-fixtures.ts @@ -59,3 +59,17 @@ export const daveRTLRtcMember = mockRtcMembership("@dave2:example.org", "DDDD"); export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, { 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("."); diff --git a/yarn.lock b/yarn.lock index d9202de2..abb3ef95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2802,10 +2802,10 @@ __metadata: languageName: node linkType: hard -"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.3.0": - version: 15.3.0 - resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.3.0" - checksum: 10c0/45628f36b7b0e54a8777ae67a7233dbdf3e3cf14e0d95d21f62f89a7ea7e3f907232f1eb7b1262193b1e227759fad47af829dcccc103ded89011f13c66f01d76 +"@matrix-org/matrix-sdk-crypto-wasm@npm:^16.0.0": + version: 16.0.0 + resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:16.0.0" + checksum: 10c0/13b4ede3e618da819957abff778afefcf3baf9a2faac04a36bb5a07a44fae2ea05fbfa072eb3408d48b2b7b9aaf27242ce52c594c8ce9bf1fb8b3aade2832be1 languageName: node linkType: hard @@ -7841,7 +7841,7 @@ __metadata: livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" 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" node-stdlib-browser: "npm:^1.3.1" normalize.css: "npm:^8.0.1" @@ -10780,12 +10780,12 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@npm:^39.2.0": +"matrix-js-sdk@matrix-org/matrix-js-sdk#2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3": 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: "@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" bs58: "npm:^6.0.0" content-type: "npm:^1.0.4" @@ -10798,7 +10798,7 @@ __metadata: sdp-transform: "npm:^3.0.0" unhomoglyph: "npm:^1.0.6" uuid: "npm:13" - checksum: 10c0/031c9ec042e00c32dc531f82fc59c64cc25fb665abfc642b1f0765c530d60684f8bd63daf0cdd0dbe96b4f87ea3f4148f9d3f024a59d57eceaec1ce5d0164755 + checksum: 10c0/feca51c7ada5a56aa6cfb74f29bd1640a20804e9de689d23f10c5227e07ba4f66ebbb9606e1384390dca277a6942886706198394717694a9cfb1f20cd36ca377 languageName: node linkType: hard