This commit is contained in:
Timo K.
2026-08-27 10:25:19 +02:00
parent 6cf0e1df51
commit dd1630be35
17 changed files with 566 additions and 112 deletions
+20 -20
View File
@@ -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
+79
View File
@@ -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,
};
}
@@ -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",
@@ -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,
+227
View File
@@ -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<OpenIDClientParts>;
let legacySpy: ReturnType<
typeof vitest.spyOn<typeof openIDSFULegacy, "getSFUConfigLegacyWithOpenID">
>;
beforeEach(() => {
matrixClient = {
getOpenIdToken: vitest.fn(),
getDeviceId: vitest.fn(),
_unstable_getLivekitToken: vitest.fn(),
_unstable_delegateDelayedLeave: vitest.fn(),
} as unknown as MockedObject<OpenIDClientParts>;
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();
});
});
+201
View File
@@ -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<SFUConfig> {
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<SFUConfig> {
// 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")
);
}
+6 -6
View File
@@ -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);
+2 -2
View File
@@ -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<Props> = ({
if (userId === null || deviceId === null) {
throw new Error("Invalid user or device ID");
}
await getSFUConfigWithOpenID(
await getSFUConfigLegacyWithOpenID(
client,
{ userId, deviceId, memberId: "" },
customLivekitUrlTextBuffer,
@@ -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";
@@ -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<LocalTransportWithSFUConfig> {
const sfuConfig = await getSFUConfigWithOpenID(
const sfuConfig = await getSFUConfigLegacyWithOpenID(
client,
membership,
transport.livekit_service_url,
@@ -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,
@@ -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<SFUConfig> {
// 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,
@@ -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";
@@ -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<
@@ -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,
@@ -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,
+1 -1
View File
@@ -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();