Merge branch 'livekit' into toger5/delayed-event-delegation

This commit is contained in:
Timo K
2026-01-05 21:08:21 +01:00
46 changed files with 2380 additions and 245 deletions

View File

@@ -165,7 +165,11 @@ interface StereoPanAudioTrackProps {
* It main purpose is to remount the AudioTrack component when switching from
* audioContext to normal audio playback.
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
* @param param0
* @param props The component props
* @param props.trackRef The track reference
* @param props.muted If the track should be muted
* @param props.audioContext The audio context to use
* @param props.audioNodes The audio nodes to use
* @returns
*/
function AudioTrackWithAudioNodes({

View File

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

View File

@@ -13,9 +13,47 @@ import { FailToGetOpenIdToken } from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config";
/**
* 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
@@ -27,15 +65,15 @@ export type OpenIDClientParts = Pick<
* Gets a bearer token from the homeserver and then use it to authenticate
* to the matrix RTC backend in order to get acces to the SFU.
* It has built-in retry for calls to the homeserver with a backoff policy.
* @param client
* @param client The Matrix client
* @param membership
* @param serviceUrl
* @param serviceUrl The URL of the livekit SFU service
* @param forceOldEndpoint This will use the old jwt endpoint which will create the rtc backend identity based on string concatination
* instead of a hash.
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
* For our own connection we can only use the hashed version if we also send the new matrix2.0 sticky events.
* @param livekitRoomAlias
* @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases.
* @param delayEndpointBaseUrl
* @param delayId
* @param logger
@@ -47,7 +85,7 @@ export async function getSFUConfigWithOpenID(
membership: CallMembershipIdentityParts,
serviceUrl: string,
forceOldJwtEndpoint: boolean,
livekitRoomAlias: string,
roomId: string,
delayEndpointBaseUrl?: string,
delayId?: string,
logger?: Logger,
@@ -68,39 +106,49 @@ export async function getSFUConfigWithOpenID(
const args: [CallMembershipIdentityParts, string, string, IOpenIDToken] = [
membership,
serviceUrl,
livekitRoomAlias,
roomId,
openIdToken,
];
let sfuConfig: { url: string; jwt: string };
try {
// we do not want to try the old endpoint, since we are not sending the new matrix2.0 sticky events (no hashed identity in the event)
if (forceOldJwtEndpoint) throw new Error("Force old jwt endpoint");
if (!delayId)
throw new Error("No delayId, Won't try matrix 2.0 jwt endpoint.");
const sfuConfig = await getLiveKitJWTWithDelayDelegation(
sfuConfig = await getLiveKitJWTWithDelayDelegation(
...args,
delayEndpointBaseUrl,
delayId,
);
logger?.info(`Got JWT from call's active focus URL.`);
return sfuConfig;
} catch (e) {
logger?.warn(
`Failed fetching jwt with matrix 2.0 endpoint (retry with legacy)`,
e,
);
const sfuConfig = await getLiveKitJWT(...args);
sfuConfig = await getLiveKitJWT(...args);
logger?.info(`Got JWT from call's active focus URL.`);
return sfuConfig;
}
} // Pull the details from the JWT
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.
livekitIdentity: payload.sub,
};
}
async function getLiveKitJWT(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
livekitRoomAlias: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
): Promise<SFUConfig> {
): Promise<{ url: string; jwt: string }> {
try {
const res = await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
@@ -108,7 +156,8 @@ async function getLiveKitJWT(
"Content-Type": "application/json",
},
body: JSON.stringify({
room: livekitRoomAlias,
// This is the actual livekit room alias. For the legacy jwt endpoint simply the room id was used.
room: matrixRoomId,
openid_token: openIDToken,
device_id: membership.deviceId,
}),
@@ -118,22 +167,22 @@ 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 });
}
}
export async function getLiveKitJWTWithDelayDelegation(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
livekitRoomAlias: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<SFUConfig> {
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const body = {
room_id: livekitRoomAlias,
room_id: matrixRoomId,
slot_id: "m.call#ROOM",
openid_token: openIDToken,
member: {