diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a20fcdeca..81c3db7402 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ importers: version: 10.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@rolldown/plugin-babel': specifier: ^0.2.3 - version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) '@sentry/react': specifier: ^8.0.0 version: 8.55.2(react@19.2.8) @@ -138,7 +138,7 @@ importers: version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) '@vitest/browser-playwright': specifier: ^4.1.5 version: 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10) @@ -192,7 +192,7 @@ importers: version: 1.9.2 matrix-js-sdk: specifier: github:matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0 + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/cd32b02c13ec118f2865a095afaa35b752da62f7 matrix-widget-api: specifier: ^1.18.0 version: 1.18.0 @@ -1191,8 +1191,8 @@ packages: '@types/dom-mediacapture-transform': ^0.1.9 livekit-client: ^1.12.0 || ^2.1.0 - '@matrix-org/matrix-sdk-crypto-wasm@18.4.0': - resolution: {integrity: sha512-osxkU1DQ+05+anGHapjWyvZqdHUb94Id37gy54mCKn1Cq/D7iGT5oEUEhjp4oTnCLo4TOtI6ULJ/LHsapaIptQ==} + '@matrix-org/matrix-sdk-crypto-wasm@18.6.0': + resolution: {integrity: sha512-P3v8849O/1+c1CYDQ1X6L1p6UpakYAav1L4wTNvwLA54n9wNpadpw8sLZoxhaG4ftlmNzxFztjRYwDvx56d11w==} engines: {node: '>= 18'} '@mdx-js/react@3.1.1': @@ -3670,9 +3670,9 @@ packages: constants-browserify@1.0.0: resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} + content-type@3.0.0: + resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==} + engines: {node: '>=22'} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -4685,9 +4685,9 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0: - resolution: {gitHosted: true, integrity: sha512-UmlkoXs9VMZvNaDeQqBb2k+gLifnIqEZc5cxrLeEwdYEDakQLbRqrFtmvIp4ipUCjdIPkCgtzb7QLlpPBLAwqA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0} - version: 42.1.0 + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/cd32b02c13ec118f2865a095afaa35b752da62f7: + resolution: {gitHosted: true, integrity: sha512-kFjj1om3TJhY3qRweBbVgSxS7n22UDZmdoC2XqTc/NwqlJtInr0rKgqjxtYr+ZmgbZdVAJvtk1NHCxUYW4ZNYg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/cd32b02c13ec118f2865a095afaa35b752da62f7} + version: 42.2.0 engines: {node: '>=22.0.0'} matrix-widget-api@1.18.0: @@ -7102,7 +7102,7 @@ snapshots: '@types/dom-mediacapture-transform': 0.1.11 livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22) - '@matrix-org/matrix-sdk-crypto-wasm@18.4.0': {} + '@matrix-org/matrix-sdk-crypto-wasm@18.6.0': {} '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8)': dependencies: @@ -8053,13 +8053,13 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) picomatch: 4.0.4 rolldown: 1.1.5 optionalDependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 8.0.0 vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) '@rolldown/pluginutils@1.0.1': {} @@ -8744,12 +8744,12 @@ snapshots: - '@types/react-dom' - react-dom - '@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) babel-plugin-react-compiler: 1.0.0 '@vitest/browser-playwright@4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10)': @@ -9195,7 +9195,7 @@ snapshots: constants-browserify@1.0.0: {} - content-type@2.0.0: {} + content-type@3.0.0: {} convert-source-map@2.0.0: {} @@ -10275,13 +10275,13 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/cd32b02c13ec118f2865a095afaa35b752da62f7: dependencies: '@babel/runtime': 8.0.0 - '@matrix-org/matrix-sdk-crypto-wasm': 18.4.0 + '@matrix-org/matrix-sdk-crypto-wasm': 18.6.0 another-json: 0.2.0 bs58: 6.0.0 - content-type: 2.0.0 + content-type: 3.0.0 loglevel: 1.9.2 matrix-events-sdk: 0.0.1 matrix-widget-api: 1.18.0 diff --git a/src/livekit/SFUConfig.ts b/src/livekit/SFUConfig.ts new file mode 100644 index 0000000000..81982d6895 --- /dev/null +++ b/src/livekit/SFUConfig.ts @@ -0,0 +1,79 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/** + * Configuration and access tokens provided by the SFU on successful authentication. + */ +export interface SFUConfig { + /** + * The WebSocket URL of the LiveKit SFU. This is what we connect the LiveKit + * room to. Note that this is NOT the JWT service URL + * (`livekit_service_url`): the legacy JWT service returns this URL as part of + * its response, while the CS-Api based flow requires us to know it upfront. + */ + url: string; + jwt: string; + livekitAlias: string; + // NOTE: Currently unused. + 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; + }; +} + +/** + * Complements the SFU websocket url and the JWT with the information encoded in + * the JWT payload itself. + * @param sfuConfig The SFU websocket url and the JWT to connect with. + * @returns The full SFU config, including the LiveKit alias and identity. + */ +export function extractFullConfigFromToken(sfuConfig: { + url: string; + jwt: string; +}): SFUConfig { + const [, payloadStr] = sfuConfig.jwt.split("."); + const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload; + return { + jwt: sfuConfig.jwt, + url: sfuConfig.url, + livekitAlias: payload.video.room, + // NOTE: Currently unused. + // Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager. + // The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys) + livekitIdentity: payload.sub, + }; +} diff --git a/src/livekit/openIDSFU.test.ts b/src/livekit/openIDSFULegacy.test.ts similarity index 95% rename from src/livekit/openIDSFU.test.ts rename to src/livekit/openIDSFULegacy.test.ts index 2ddb6c95cc..619cbab03c 100644 --- a/src/livekit/openIDSFU.test.ts +++ b/src/livekit/openIDSFULegacy.test.ts @@ -17,7 +17,7 @@ import { import fetchMock from "fetch-mock"; import { MatrixError } from "matrix-js-sdk"; -import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU"; +import { getSFUConfigLegacyWithOpenID, type OpenIDClientParts } from "./openIDSFULegacy"; import { testJWTToken } from "../utils/test-fixtures"; import { ownMemberMock } from "../utils/test"; import { FailToGetOpenIdToken } from "../utils/errors"; @@ -45,7 +45,7 @@ describe("getSFUConfigWithOpenID", () => { body: { url: sfuUrl, jwt: testJWTToken }, }; }); - const config = await getSFUConfigWithOpenID( + const config = await getSFUConfigLegacyWithOpenID( matrixClient, ownMemberMock, "https://sfu.example.org", @@ -71,7 +71,7 @@ describe("getSFUConfigWithOpenID", () => { }; }); try { - await getSFUConfigWithOpenID( + await getSFUConfigLegacyWithOpenID( matrixClient, ownMemberMock, "https://sfu.example.org", @@ -123,7 +123,7 @@ describe("getSFUConfigWithOpenID", () => { ); // Note: Assuming getSFUConfigWithOpenID eventually calls getLiveKitJWT - const config = await getSFUConfigWithOpenID( + const config = await getSFUConfigLegacyWithOpenID( matrixClient, ownMemberMock, "https://sfu.example.org", @@ -164,7 +164,7 @@ describe("getSFUConfigWithOpenID", () => { { overwriteRoutes: true }, ); - const config = await getSFUConfigWithOpenID( + const config = await getSFUConfigLegacyWithOpenID( matrixClient, ownMemberMock, "https://sfu.example.org", @@ -204,7 +204,7 @@ describe("getSFUConfigWithOpenID", () => { }; }); try { - await getSFUConfigWithOpenID( + await getSFUConfigLegacyWithOpenID( matrixClient, ownMemberMock, "https://sfu.example.org", @@ -261,7 +261,7 @@ describe("getSFUConfigWithOpenID", () => { }; }); try { - await getSFUConfigWithOpenID( + await getSFUConfigLegacyWithOpenID( matrixClient, ownMemberMock, "https://sfu.example.org", @@ -312,7 +312,7 @@ describe("getSFUConfigWithOpenID", () => { body: { url: sfuUrl, jwt: testJWTToken }, }; }); - const config = await getSFUConfigWithOpenID( + const config = await getSFUConfigLegacyWithOpenID( matrixClient, ownMemberMock, "https://sfu.example.org", diff --git a/src/livekit/openIDSFU.ts b/src/livekit/openIDSFULegacy.ts similarity index 83% rename from src/livekit/openIDSFU.ts rename to src/livekit/openIDSFULegacy.ts index 00cf69b1a8..5ee35ff7a1 100644 --- a/src/livekit/openIDSFU.ts +++ b/src/livekit/openIDSFULegacy.ts @@ -20,55 +20,19 @@ import { import { doNetworkOperationWithRetry } from "../utils/matrix"; import { Config } from "../config/Config"; import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport"; +import { extractFullConfigFromToken, type SFUConfig } from "./SFUConfig.ts"; -/** - * Configuration and access tokens provided by the SFU on successful authentication. - */ -export interface SFUConfig { - url: string; - jwt: string; - livekitAlias: string; - // NOTE: Currently unused. - 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; - }; -} +// Re-exported for the many existing consumers that import `SFUConfig` from +// here. New code should import it from `./SFUConfig.ts` directly. +export type { SFUConfig }; // The bits we need from MatrixClient export type OpenIDClientParts = Pick< MatrixClient, - "getOpenIdToken" | "getDeviceId" + | "getOpenIdToken" + | "getDeviceId" + | "_unstable_getLivekitToken" + | "_unstable_delegateDelayedLeave" >; /** @@ -91,7 +55,7 @@ export type OpenIDClientParts = Pick< * @returns Object containing the token information * @throws FailToGetOpenIdToken */ -export async function getSFUConfigWithOpenID( +export async function getSFUConfigLegacyWithOpenID( client: OpenIDClientParts, membership: CallMembershipIdentityParts, serviceUrl: string, @@ -171,23 +135,6 @@ export async function getSFUConfigWithOpenID( } } -function extractFullConfigFromToken(sfuConfig: { - url: string; - jwt: string; -}): SFUConfig { - const [, payloadStr] = sfuConfig.jwt.split("."); - const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload; - return { - jwt: sfuConfig.jwt, - url: sfuConfig.url, - livekitAlias: payload.video.room, - // NOTE: Currently unused. - // Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager. - // The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys) - livekitIdentity: payload.sub, - }; -} - async function getLiveKitJWT( deviceId: string, livekitServiceURL: string, @@ -264,7 +211,7 @@ class NotSupportedError extends Error { } } -export async function getLiveKitJWTWithDelayDelegation( +async function getLiveKitJWTWithDelayDelegation( membership: CallMembershipIdentityParts, livekitServiceURL: string, matrixRoomId: string, diff --git a/src/livekit/sfuAuthAndDelegation.test.ts b/src/livekit/sfuAuthAndDelegation.test.ts new file mode 100644 index 0000000000..f8a0b3f64e --- /dev/null +++ b/src/livekit/sfuAuthAndDelegation.test.ts @@ -0,0 +1,227 @@ +/* +Copyright 2026 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 { + afterEach, + beforeEach, + describe, + expect, + it, + type MockedObject, + vitest, +} from "vitest"; +import { MatrixError } from "matrix-js-sdk"; + +import { getSFUToken } from "./sfuAuthAndDelegation"; +import * as openIDSFULegacy from "./openIDSFULegacy"; +import { type OpenIDClientParts } from "./openIDSFULegacy"; +import { type SFUConfig } from "./SFUConfig"; +import { testJWTToken } from "../utils/test-fixtures"; +import { ownMemberMock } from "../utils/test"; +import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport"; + +const serviceUrl = "https://sfu.example.org"; +const wsUrl = "wss://livekit.example.org"; +const roomId = "!example_room_id"; + +const legacyConfig: SFUConfig = { + url: "wss://legacy.example.org", + jwt: "legacy_jwt", + livekitAlias: roomId, + livekitIdentity: "@alice:example.org:DEVICE", +}; + +const expectedMember = { + id: ownMemberMock.memberId, + claimed_device_id: ownMemberMock.deviceId, +}; + +describe("getSFUToken", () => { + let matrixClient: MockedObject; + let legacySpy: ReturnType< + typeof vitest.spyOn + >; + + beforeEach(() => { + matrixClient = { + getOpenIdToken: vitest.fn(), + getDeviceId: vitest.fn(), + _unstable_getLivekitToken: vitest.fn(), + _unstable_delegateDelayedLeave: vitest.fn(), + } as unknown as MockedObject; + legacySpy = vitest + .spyOn(openIDSFULegacy, "getSFUConfigLegacyWithOpenID") + .mockResolvedValue(legacyConfig); + }); + + afterEach(() => { + vitest.restoreAllMocks(); + }); + + it("uses the CS-Api in matrix 2.0 mode", async () => { + matrixClient._unstable_getLivekitToken.mockResolvedValue({ + jwt: testJWTToken, + }); + + const config = await getSFUToken( + matrixClient, + ownMemberMock, + serviceUrl, + roomId, + { + wsUrl, + forceJwtEndpoint: JwtEndpointVersion.Matrix_2_0, + }, + ); + + expect(matrixClient._unstable_getLivekitToken).toHaveBeenCalledWith({ + url: wsUrl, + room_id: roomId, + slot_id: "m.call#ROOM", + member: expectedMember, + }); + // The url we connect the LiveKit room to must be the SFU websocket url, not + // the JWT service url. + expect(config).toEqual({ + jwt: testJWTToken, + url: wsUrl, + livekitAlias: roomId, + livekitIdentity: "@me:example.org:ABCDEF", + }); + expect(legacySpy).not.toHaveBeenCalled(); + }); + + it("delegates the delayed leave event when a delay id is given", async () => { + matrixClient._unstable_getLivekitToken.mockResolvedValue({ + jwt: testJWTToken, + }); + + await getSFUToken(matrixClient, ownMemberMock, serviceUrl, roomId, { + wsUrl, + forceJwtEndpoint: JwtEndpointVersion.Matrix_2_0, + delayId: "delay_id", + }); + + expect(matrixClient._unstable_delegateDelayedLeave).toHaveBeenCalledWith({ + room_id: roomId, + slot_id: "m.call#ROOM", + member: expectedMember, + delay_id: "delay_id", + }); + }); + + it("does not delegate without a delay id", async () => { + matrixClient._unstable_getLivekitToken.mockResolvedValue({ + jwt: testJWTToken, + }); + + await getSFUToken(matrixClient, ownMemberMock, serviceUrl, roomId, { + wsUrl, + forceJwtEndpoint: JwtEndpointVersion.Matrix_2_0, + }); + + expect(matrixClient._unstable_delegateDelayedLeave).not.toHaveBeenCalled(); + }); + + it("keeps the token if delegating the delayed leave event fails", async () => { + matrixClient._unstable_getLivekitToken.mockResolvedValue({ + jwt: testJWTToken, + }); + matrixClient._unstable_delegateDelayedLeave.mockRejectedValue( + // The homeserver rejects delegation if the delay is shorter than an hour. + new MatrixError({ errcode: "M_BAD_JSON", error: "Delay too short" }, 400), + ); + + const config = await getSFUToken( + matrixClient, + ownMemberMock, + serviceUrl, + roomId, + { + wsUrl, + forceJwtEndpoint: JwtEndpointVersion.Matrix_2_0, + delayId: "delay_id", + }, + ); + + expect(config.jwt).toBe(testJWTToken); + expect(legacySpy).not.toHaveBeenCalled(); + }); + + it.each([ + ["M_NOT_FOUND", 404], + ["M_UNRECOGNIZED", 404], + ])( + "falls back to the legacy flow if the homeserver replies %s", + async (errcode, httpStatus) => { + matrixClient._unstable_getLivekitToken.mockRejectedValue( + new MatrixError({ errcode, error: "Unknown endpoint" }, httpStatus), + ); + + const opts = { + wsUrl, + forceJwtEndpoint: JwtEndpointVersion.Matrix_2_0, + delayId: "delay_id", + }; + const config = await getSFUToken( + matrixClient, + ownMemberMock, + serviceUrl, + roomId, + opts, + ); + + expect(config).toEqual(legacyConfig); + expect(legacySpy).toHaveBeenCalledWith( + matrixClient, + ownMemberMock, + serviceUrl, + roomId, + opts, + undefined, + ); + }, + ); + + it("does not fall back if the CS-Api fails for another reason", async () => { + const error = new MatrixError( + { errcode: "M_FORBIDDEN", error: "Not joined to the room" }, + 403, + ); + matrixClient._unstable_getLivekitToken.mockRejectedValue(error); + + await expect( + getSFUToken(matrixClient, ownMemberMock, serviceUrl, roomId, { + wsUrl, + forceJwtEndpoint: JwtEndpointVersion.Matrix_2_0, + }), + ).rejects.toBe(error); + expect(legacySpy).not.toHaveBeenCalled(); + }); + + it("does not try the CS-Api without a websocket url", async () => { + await getSFUToken(matrixClient, ownMemberMock, serviceUrl, roomId, { + forceJwtEndpoint: JwtEndpointVersion.Matrix_2_0, + }); + + expect(matrixClient._unstable_getLivekitToken).not.toHaveBeenCalled(); + expect(legacySpy).toHaveBeenCalled(); + }); + + it.each([ + ["the legacy endpoint is forced", JwtEndpointVersion.Legacy], + ["no endpoint version is given", undefined], + ])("does not try the CS-Api if %s", async (_name, forceJwtEndpoint) => { + await getSFUToken(matrixClient, ownMemberMock, serviceUrl, roomId, { + wsUrl, + forceJwtEndpoint, + }); + + expect(matrixClient._unstable_getLivekitToken).not.toHaveBeenCalled(); + expect(legacySpy).toHaveBeenCalled(); + }); +}); diff --git a/src/livekit/sfuAuthAndDelegation.ts b/src/livekit/sfuAuthAndDelegation.ts new file mode 100644 index 0000000000..ef9f2731db --- /dev/null +++ b/src/livekit/sfuAuthAndDelegation.ts @@ -0,0 +1,201 @@ +/* +Copyright 2026 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 { MatrixError } from "matrix-js-sdk"; +import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; +import { type LivekitRtcMember } from "matrix-js-sdk/lib/matrixrtc"; +import { type Logger } from "matrix-js-sdk/lib/logger"; + +import { + getSFUConfigLegacyWithOpenID, + type OpenIDClientParts, +} from "./openIDSFULegacy.ts"; +import { extractFullConfigFromToken, type SFUConfig } from "./SFUConfig.ts"; +import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport.ts"; +import { doNetworkOperationWithRetry } from "../utils/matrix.ts"; + +// TODO: This should come from the `MatrixRTCSession`s slot description instead +// of being hardcoded here. (the legacy flow hardcodes it as well) +const SLOT_ID = "m.call#ROOM"; + +/** + * Authenticates with the matrix RTC backend (SFU) and, if possible, hands the + * management of our delayed leave event over to the homeserver. + * + * Two flows exist: + * - The CS-Api based flow (MSC4195): the homeserver authenticates us with the + * SFU. This requires matrix 2.0 (sticky events) and homeserver support. + * - The legacy flow: we get an openID token from the homeserver and hand it to + * the JWT service sitting next to the SFU. See {@link getSFUConfigLegacyWithOpenID}. + * + * We only try the CS-Api flow if we are in matrix 2.0 mode and know the SFU + * websocket url. If the homeserver does not implement the (unstable) MSC4195 + * endpoints we fall back to the legacy flow. + * @param client The Matrix client + * @param membership Our own membership identity parts, used to identify the + * `m.rtc.member` event we want a token for. + * @param serviceUrl The URL of the JWT service next to the livekit SFU. Only used by the legacy flow. + * @param roomId The room id used in the token request. This is NOT the livekit_alias. + * The livekit alias is provided as part of the JWT payload. + * @param opts Additional options to modify which endpoint with which data will be used to acquire the jwt token. + * @param opts.wsUrl The websocket URL of the livekit SFU. Comes from the transport + * (either from the `/transports` endpoint or from a remote member's transport field). + * The CS-Api flow cannot be used without it, since the homeserver needs to be + * told which of its SFUs to get a token from. + * @param opts.forceJwtEndpoint Which endpoint version to use. + * {@link JwtEndpointVersion.Matrix_2_0} is what enables the CS-Api flow: we can + * only use the hashed rtc backend identity if we also send the new matrix 2.0 + * sticky events. For remote connections this does not matter, since we do not + * publish there, so `undefined` (try whatever the JWT service supports) is used. + * @param opts.delayEndpointBaseUrl The URL of the matrix homeserver. Only used + * by the legacy flow: the homeserver knows its own CS-Api url. + * @param opts.delayId The delay id of the delayed leave event to delegate. + * @param logger optional logger. + * @returns Object containing the token information + * @throws FailToGetOpenIdToken | NoMatrix2AuthorizationService | MatrixError + */ + // TODO multiple participatns on remote sfu with different transports + // {livekit_service_url: http://} + // {url: ws://, livekit_service_url: http://} + // conclusion?: connect with newest stack (wsUrl + cs-api). Should end up on same sfu only subscribe, hence own identity does not matter +export async function getSFUToken( + client: OpenIDClientParts, + membership: CallMembershipIdentityParts, + serviceUrl: string, + roomId: string, + opts?: { + wsUrl?: string; + forceJwtEndpoint?: JwtEndpointVersion; + delayEndpointBaseUrl?: string; + delayId?: string; + }, + logger?: Logger, +): Promise { + const wsUrl = opts?.wsUrl; + const inMatrix2Mode = + opts?.forceJwtEndpoint === JwtEndpointVersion.Matrix_2_0; + + if (inMatrix2Mode && wsUrl !== undefined) { + try { + logger?.info(`Trying to get a SFU token via the CS-Api for ${wsUrl}...`); + return await getSFUConfigWithCSApi( + client, + membership, + wsUrl, + roomId, + opts?.delayId, + logger, + ); + } catch (e) { + // Anything but "the homeserver does not know these endpoints" is a real + // error (e.g. we are not joined to the room, or the SFU does not belong + // to the answering server). Falling back would only mask it. + if (!isEndpointUnsupported(e)) throw e; + logger?.info( + `Homeserver does not support the MSC4195 CS-Api endpoints. Falling back to the openID based flow.`, + e, + ); + } + } + + return await getSFUConfigLegacyWithOpenID( + client, + membership, + serviceUrl, + roomId, + opts, + logger, + ); +} + +/** + * Gets a SFU token from the homeserver (MSC4195) and delegates the delayed leave + * event to it. + * + * Delegating the delayed leave event is best effort: if it fails we still have a + * usable token and just have to keep restarting the delayed event ourselves. + * @param client The Matrix client + * @param membership Our own membership identity parts. + * @param wsUrl The websocket URL of the livekit SFU to get a token for. + * @param roomId The room id of the `m.rtc.member` event. + * @param delayId The delay id of the delayed leave event to delegate. If + * `undefined` no delegation is attempted. + * @param logger optional logger. + * @returns Object containing the token information + * @throws MatrixError + */ +async function getSFUConfigWithCSApi( + client: OpenIDClientParts, + membership: CallMembershipIdentityParts, + wsUrl: string, + roomId: string, + delayId?: string, + logger?: Logger, +): Promise { + // The homeserver knows our user id from the access token, so we only claim + // the device id here. + const member: LivekitRtcMember = { + id: membership.memberId, + claimed_device_id: membership.deviceId, + }; + + const { jwt } = await doNetworkOperationWithRetry(async () => + client._unstable_getLivekitToken({ + url: wsUrl, + room_id: roomId, + slot_id: SLOT_ID, + member, + }), + ); + logger?.info(`Got SFU token via the CS-Api for ${wsUrl}`); + const sfuConfig = extractFullConfigFromToken({ url: wsUrl, jwt }); + + if (delayId !== undefined) { + try { + await doNetworkOperationWithRetry(async () => + client._unstable_delegateDelayedLeave({ + room_id: roomId, + slot_id: SLOT_ID, + member, + delay_id: delayId, + }), + ); + logger?.info( + `Delegated delayed leave event ${delayId} to the homeserver`, + ); + } catch (e) { + // Not fatal: we have a working token. Note that the homeserver rejects + // delegation with M_BAD_JSON if the delayed event's timeout is below one + // hour, so this is expected for deployments with a short + // `delayed_leave_event_delay_ms`. + logger?.warn( + `Failed to delegate delayed leave event ${delayId} to the homeserver. We need to keep restarting it ourselves.`, + e, + ); + } + } + + return sfuConfig; +} + +/** + * Checks whether an error means "this homeserver does not implement the + * (unstable) MSC4195 endpoints". + * + * MSC4195 documents `M_NOT_FOUND` for this, but a homeserver without support + * answers with whatever it uses for unknown endpoints (Synapse: 404 with + * `M_UNRECOGNIZED`), so we accept any 404 as "unsupported". + * @param e The error to check. + */ +function isEndpointUnsupported(e: unknown): boolean { + return ( + e instanceof MatrixError && + (e.httpStatus === 404 || + e.errcode === "M_NOT_FOUND" || + e.errcode === "M_UNRECOGNIZED") + ); +} diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index d4c7b8c8f2..d1c73045d7 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -13,7 +13,7 @@ import { TooltipProvider } from "@vector-im/compound-web"; import type { MatrixClient } from "matrix-js-sdk"; import type { Room as LivekitRoom } from "livekit-client"; import { DeveloperSettingsTab } from "./DeveloperSettingsTab"; -import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; +import { getSFUConfigWithOpenID } from "../livekit/openIDSFULegacy"; import { customLivekitUrl as customLivekitUrlSetting, enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting, @@ -146,7 +146,7 @@ describe("DeveloperSettingsTab", () => { const saveButton = screen.getByRole("button", { name: "Save" }); await user.click(saveButton); - expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + expect(getSFUConfigLegacyWithOpenID).not.toHaveBeenCalled(); expect(customLivekitUrlSetting.getValue()).toBe(null); }); @@ -168,7 +168,7 @@ describe("DeveloperSettingsTab", () => { const saveButton = screen.getByRole("button", { name: "Save" }); await user.click(saveButton); - expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + expect(getSFUConfigLegacyWithOpenID).not.toHaveBeenCalled(); expect(customLivekitUrlSetting.getValue()).toBe(null); }); @@ -193,7 +193,7 @@ describe("DeveloperSettingsTab", () => { name: "Reset overwrite", }); await user.click(cancelButton); - expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + expect(getSFUConfigLegacyWithOpenID).not.toHaveBeenCalled(); expect(customLivekitUrlSetting.getValue()).toBe(null); }); @@ -216,7 +216,7 @@ describe("DeveloperSettingsTab", () => { const saveButton = screen.getByRole("button", { name: "Save" }); await user.click(saveButton); - expect(getSFUConfigWithOpenID).toHaveBeenCalledWith( + expect(getSFUConfigLegacyWithOpenID).toHaveBeenCalledWith( expect.anything(), expect.anything(), "wss://example.livekit.valid", @@ -245,7 +245,7 @@ describe("DeveloperSettingsTab", () => { await user.type(input, "wss://example.livekit.valid"); const saveButton = screen.getByRole("button", { name: "Save" }); - (getSFUConfigWithOpenID as Mock).mockImplementation(() => { + (getSFUConfigLegacyWithOpenID as Mock).mockImplementation(() => { throw new Error("Invalid URL"); }); await user.click(saveButton); diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 70db13db93..bf81731426 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -67,7 +67,7 @@ import styles from "./DeveloperSettingsTab.module.css"; import settingsStyles from "./SettingsModal.module.css"; import { Slider } from "../Slider"; import { useUrlParams } from "../UrlParams"; -import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; +import { getSFUConfigLegacyWithOpenID } from "../livekit/openIDSFULegacy"; interface Props { client: MatrixClient; @@ -481,7 +481,7 @@ export const DeveloperSettingsTab: FC = ({ if (userId === null || deviceId === null) { throw new Error("Invalid user or device ID"); } - await getSFUConfigWithOpenID( + await getSFUConfigLegacyWithOpenID( client, { userId, deviceId, memberId: "" }, customLivekitUrlTextBuffer, diff --git a/src/state/CallViewModel/localMember/LocalTransport.test.ts b/src/state/CallViewModel/localMember/LocalTransport.test.ts index 89cb831daa..ed65dc530d 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.test.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.test.ts @@ -40,7 +40,7 @@ import { MatrixRTCTransportMissingError, FailToGetOpenIdToken, } from "../../../utils/errors"; -import * as openIDSFU from "../../../livekit/openIDSFU"; +import * as openIDSFU from "../../../livekit/openIDSFULegacy"; import { customLivekitUrl } from "../../../settings/settings"; import { testJWTToken } from "../../../utils/test-fixtures"; diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 1a6dddc1f8..fdb5a3b3e0 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -37,10 +37,10 @@ import { NoMatrix2AuthorizationService, } from "../../../utils/errors.ts"; import { - getSFUConfigWithOpenID, + getSFUConfigLegacyWithOpenID, type SFUConfig, type OpenIDClientParts, -} from "../../../livekit/openIDSFU.ts"; +} from "../../../livekit/openIDSFULegacy.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; import { customLivekitUrl } from "../../../settings/settings.ts"; import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts"; @@ -313,7 +313,7 @@ async function doOpenIdAndJWTFromUrl( delayId?: string, logger?: Logger, ): Promise { - const sfuConfig = await getSFUConfigWithOpenID( + const sfuConfig = await getSFUConfigLegacyWithOpenID( client, membership, transport.livekit_service_url, diff --git a/src/state/CallViewModel/remoteMembers/Connection.test.ts b/src/state/CallViewModel/remoteMembers/Connection.test.ts index 7232958530..39bca205a9 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.test.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.test.ts @@ -35,7 +35,7 @@ import { type ConnectionOpts, } from "./Connection.ts"; import { ObservableScope } from "../../ObservableScope.ts"; -import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; +import { type OpenIDClientParts } from "../../../livekit/openIDSFULegacy.ts"; import { ElementCallError, FailToGetOpenIdToken, diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index 013bd96c7b..a34d9d2ea7 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -22,10 +22,10 @@ import { type Logger } from "matrix-js-sdk/lib/logger"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { - getSFUConfigWithOpenID, + getSFUConfigLegacyWithOpenID, type OpenIDClientParts, type SFUConfig, -} from "../../../livekit/openIDSFU.ts"; +} from "../../../livekit/openIDSFULegacy.ts"; import { type Behavior } from "../../Behavior.ts"; import { type ObservableScope } from "../../ObservableScope.ts"; import { @@ -280,7 +280,7 @@ export class Connection { protected async getSFUConfigForRemoteConnection(): Promise { // This will only be called for sfu's where we do not publish ourselves. // For the local connection we will use the existingJwtTokenData - return await getSFUConfigWithOpenID( + return await getSFUConfigLegacyWithOpenID( this.client, this.ownMembershipIdentity, this.transport.livekit_service_url, diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 30ff37f3e1..478b53248a 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -23,7 +23,7 @@ import { Connection } from "./Connection.ts"; import type { OpenIDClientParts, SFUConfig, -} from "../../../livekit/openIDSFU.ts"; +} from "../../../livekit/openIDSFULegacy.ts"; import type { MediaDevices } from "../../MediaDevices.ts"; import type { Behavior } from "../../Behavior.ts"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts index 727f68bccf..1455d4ca7c 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts @@ -22,7 +22,7 @@ import { isLocalTransportWithSFUConfig, type LocalTransportWithSFUConfig, } from "../localMember/LocalTransport.ts"; -import { type SFUConfig } from "../../../livekit/openIDSFU.ts"; +import { type SFUConfig } from "../../../livekit/openIDSFULegacy.ts"; export class ConnectionManagerData { private readonly store: Map< diff --git a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts index 411ae20011..9abbd55628 100644 --- a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts +++ b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts @@ -14,7 +14,7 @@ import EventEmitter from "events"; import { ObservableScope } from "../../ObservableScope.ts"; import { ECConnectionFactory } from "./ConnectionFactory.ts"; -import type { OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; +import type { OpenIDClientParts } from "../../../livekit/openIDSFULegacy.ts"; import { exampleTransport, mockMediaDevices, diff --git a/src/state/CallViewModel/remoteMembers/integration.test.ts b/src/state/CallViewModel/remoteMembers/integration.test.ts index 67d15a38f5..230880d273 100644 --- a/src/state/CallViewModel/remoteMembers/integration.test.ts +++ b/src/state/CallViewModel/remoteMembers/integration.test.ts @@ -19,7 +19,7 @@ import { trackEpoch, } from "../../ObservableScope.ts"; import { ECConnectionFactory } from "./ConnectionFactory.ts"; -import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; +import { type OpenIDClientParts } from "../../../livekit/openIDSFULegacy.ts"; import { mockMediaDevices, mockRtcMembership, diff --git a/src/utils/test.ts b/src/utils/test.ts index 206db88f54..25f42c992d 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -76,7 +76,7 @@ import { type RemoteScreenShareViewModel, } from "../state/media/RemoteScreenShareViewModel"; import { Connection } from "../state/CallViewModel/remoteMembers/Connection"; -import { type SFUConfig } from "../livekit/openIDSFU"; +import { type SFUConfig } from "../livekit/openIDSFULegacy"; export function withFakeTimers(continuation: () => void): void { vi.useFakeTimers();