diff --git a/src/livekit/auth/csApi.ts b/src/livekit/auth/csApi.ts deleted file mode 100644 index d62111f02..000000000 --- a/src/livekit/auth/csApi.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* -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 { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk"; -import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; - -import { extractFullConfigFromToken, type SFUConfig } from "./types.ts"; -import { doNetworkOperationWithRetry } from "../../utils/matrix.ts"; - -/** - * In widget mode the host may refuse an action before any request is made - * which surfaces as a plain error. Asking again cannot change its answer, - * so only errors from the network are worth retrying. - */ -function isNetworkError(e: unknown): boolean { - return e instanceof HTTPError || e instanceof ConnectionError; -} - -export type ClientGetTokenParts = Pick< - MatrixClient, - "_unstable_getLivekitToken" ->; - -export interface GetSFUConfigParams { - /** - * The Matrix client. - */ - client: ClientGetTokenParts; - /** - * Data identifying the local user's session membership. - */ - membership: CallMembershipIdentityParts; - /** - * The WebSocket URL of the SFU for which we wish to get an access token. - */ - url: string; - /** - * The name of the homeserver to which the SFU belongs. - */ - serverName: string; - /** - * The ID of the Matrix room in which the session takes place. - */ - roomId: string; - /** - * The ID of the MatrixRTC slot in which the session takes place. - */ - slotId: string; -} - -/** - * Gets an {@link SFUConfig} appropriate for connecting to a given SFU, using - * the MSC4195 LiveKit client-server API endpoints. - */ -export async function getSFUConfig({ - client, - membership, - url, - serverName, - roomId, - slotId, -}: GetSFUConfigParams): Promise { - const res = await doNetworkOperationWithRetry( - async () => - client._unstable_getLivekitToken({ - url, - server_name: serverName, - room_id: roomId, - slot_id: slotId, - member_id: membership.memberId, - }), - isNetworkError, - ); - return extractFullConfigFromToken({ url, jwt: res.jwt }); -} - -export type ClientDelegationParts = Pick< - MatrixClient, - "_unstable_delegateDelayedLeave" | "baseUrl" ->; - -export interface DelegateDelayedLeaveParams { - /** - * The Matrix client. - */ - client: ClientDelegationParts; - /** - * Data identifying the local user's session membership. - */ - membership: CallMembershipIdentityParts; - /** - * The WebSocket URL of the SFU to which we are publishing and wish to - * delegate the delayed leave event. - */ - url: string; - /** - * The ID of the Matrix room in which the session takes place. - */ - roomId: string; - /** - * The ID of the MatrixRTC slot in which the session takes place. - */ - slotId: string; - /** - * The delay ID of the leave event to be delegated. - */ - delayId: string; -} - -/** - * Delegates a delayed leave event to a given SFU, so that the event will - * be sent on our behalf whenever we disconnect from the SFU. - */ -export async function delegateDelayedLeave({ - client, - membership, - url, - roomId, - slotId, - delayId, -}: DelegateDelayedLeaveParams): Promise { - await doNetworkOperationWithRetry( - async () => - client._unstable_delegateDelayedLeave({ - url, - room_id: roomId, - slot_id: slotId, - member_id: membership.memberId, - delay_id: delayId, - }), - isNetworkError, - ); -} diff --git a/src/livekit/auth/index.test.ts b/src/livekit/auth/index.test.ts deleted file mode 100644 index 6c48c5032..000000000 --- a/src/livekit/auth/index.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/* -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, expect, type Mocked, vi, describe, it } from "vitest"; -import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; -import { logger } from "matrix-js-sdk/lib/logger"; - -import { - aliceDeviceId, - aliceId, - aliceUserId, - createJWTToken, -} from "../../utils/test-fixtures"; -import type * as csApi from "./csApi"; -import * as openID from "./openID"; -import { delegateDelayedLeave, getSFUConfig } from "."; -import { mocked } from "storybook/test"; -import { ConnectionError, MatrixError } from "matrix-js-sdk"; -import { NoMatrix2AuthorizationService } from "../../utils/errors"; -import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; - -vi.mock("./openID", () => ({ getSFUConfig: vi.fn() })); -vi.useFakeTimers(); - -const csApiJWTToken = createJWTToken(aliceId, "via CS API"); -const openIDJWTToken = createJWTToken(aliceId, "via OpenID"); - -mocked(openID).getSFUConfig.mockResolvedValue({ - url: "mock-openID-url", - jwt: openIDJWTToken, - livekitAlias: "via OpenID", - livekitIdentity: aliceId, -}); - -const client: Mocked< - csApi.ClientGetTokenParts & - csApi.ClientDelegationParts & - openID.ClientOpenIDParts -> = { - baseUrl: "https://matrix.example.org", - _unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: csApiJWTToken }), - _unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}), - getOpenIdToken: vi - .fn() - .mockResolvedValue({ access_token: "mock-openID-token" }), -}; - -const membership: CallMembershipIdentityParts = { - userId: aliceUserId, - deviceId: aliceDeviceId, - memberId: "123", -}; - -const roomId = "!room:example.org"; -const slotId = "m.call#ROOM"; - -const unsupportedError = new MatrixError({ errcode: "M_UNSUPPORTED" }, 404); - -const urlTransport: UnstableLivekitTransport = { - type: "livekit", - url: "wss://livekit.example.org", -}; - -const serviceUrlTransport: UnstableLivekitTransport = { - type: "livekit", - livekit_service_url: "https://livekit-jwt.example.org", -}; - -const urlAndServiceUrlTransport: UnstableLivekitTransport = { - ...urlTransport, - ...serviceUrlTransport, -}; - -afterEach(() => vi.clearAllMocks()); - -describe("getSFUConfig", () => { - const defaultParams = { - client, - membership, - serverName: "example.org", - role: "subscriber" as const, - roomId, - slotId, - logger, - }; - - it("uses CS API with url transport", async () => { - expect( - await getSFUConfig({ ...defaultParams, transport: urlTransport }), - ).toEqual( - expect.objectContaining({ - jwt: csApiJWTToken, - livekitAlias: "via CS API", - }), - ); - expect(client._unstable_getLivekitToken).toHaveBeenCalled(); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); - - it("does not fall back to OpenID with url transport", async () => { - client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError); - await expect( - getSFUConfig({ ...defaultParams, transport: urlTransport }), - ).rejects.toThrow(expect.any(NoMatrix2AuthorizationService)); - expect(client._unstable_getLivekitToken).toHaveBeenCalled(); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); - - it("uses OpenID with livekit_service_url transport", async () => { - expect( - await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }), - ).toEqual( - expect.objectContaining({ - jwt: openIDJWTToken, - livekitAlias: "via OpenID", - }), - ); - expect(client._unstable_getLivekitToken).not.toHaveBeenCalled(); - expect(openID.getSFUConfig).toHaveBeenCalled(); - }); - - it("uses CS API with url + livekit_service_url transport", async () => { - expect( - await getSFUConfig({ - ...defaultParams, - transport: urlAndServiceUrlTransport, - }), - ).toEqual( - expect.objectContaining({ - jwt: csApiJWTToken, - livekitAlias: "via CS API", - }), - ); - expect(client._unstable_getLivekitToken).toHaveBeenCalled(); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); - - it("falls back to OpenID with url + livekit_service_url transport", async () => { - client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError); - expect( - await getSFUConfig({ - ...defaultParams, - transport: urlAndServiceUrlTransport, - }), - ).toEqual( - expect.objectContaining({ - jwt: openIDJWTToken, - livekitAlias: "via OpenID", - }), - ); - expect(client._unstable_getLivekitToken).toHaveBeenCalled(); - expect(openID.getSFUConfig).toHaveBeenCalled(); - }); - - it("falls back to OpenID when the widget host refuses the CS API", async () => { - // In widget mode a host without MSC4533 rejects the request before it - // reaches the homeserver, with a plain error rather than a Matrix error - client._unstable_getLivekitToken.mockRejectedValueOnce( - new Error( - "The rtc_livekit_get_token action is not supported by the client.", - ), - ); - expect( - await getSFUConfig({ - ...defaultParams, - transport: urlAndServiceUrlTransport, - }), - ).toEqual(expect.objectContaining({ jwt: openIDJWTToken })); - expect(openID.getSFUConfig).toHaveBeenCalled(); - }); - - it("surfaces the widget host's refusal with url transport", async () => { - const error = new Error( - "The rtc_livekit_get_token action is not supported by the client.", - ); - client._unstable_getLivekitToken.mockRejectedValueOnce(error); - await expect( - getSFUConfig({ ...defaultParams, transport: urlTransport }), - ).rejects.toThrow(error); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); - - it("uses CS API when publishing with url transport", async () => { - expect( - await getSFUConfig({ - ...defaultParams, - transport: urlTransport, - role: "publisher", - }), - ).toEqual(expect.objectContaining({ jwt: csApiJWTToken })); - expect(client._unstable_getLivekitToken).toHaveBeenCalled(); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); - - it("does not fall back to OpenID when publishing", async () => { - client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError); - await expect( - getSFUConfig({ - ...defaultParams, - transport: urlTransport, - role: "publisher", - }), - ).rejects.toThrow(expect.any(NoMatrix2AuthorizationService)); - expect(client._unstable_getLivekitToken).toHaveBeenCalled(); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); - - it("uses OpenID when publishing with url + livekit_service_url transport", async () => { - // Subscribers predating MSC4195 expect a legacy participant identity from - // anyone advertising a state event, which only the JWT service assigns - expect( - await getSFUConfig({ - ...defaultParams, - transport: urlAndServiceUrlTransport, - role: "publisher", - }), - ).toEqual( - expect.objectContaining({ - jwt: openIDJWTToken, - livekitAlias: "via OpenID", - }), - ); - expect(client._unstable_getLivekitToken).not.toHaveBeenCalled(); - expect(openID.getSFUConfig).toHaveBeenCalledWith( - expect.objectContaining({ role: "publisher" }), - ); - }); - - it("does not fall back to OpenID on connection error", async () => { - const client_ = { - ...client, - _unstable_getLivekitToken: vi - .fn() - .mockRejectedValue(new ConnectionError("No connection")), - }; - await Promise.all([ - expect( - getSFUConfig({ - ...defaultParams, - client: client_, - transport: urlAndServiceUrlTransport, - }), - ).rejects.toThrow(expect.any(ConnectionError)), - vi.runAllTimersAsync(), // Allow request to be retried - ]); - expect(client_._unstable_getLivekitToken).toHaveBeenCalled(); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); -}); - -describe("delegateDelayedLeave", () => { - const defaultParams = { - client, - membership, - roomId, - slotId, - delayId: "delay123", - logger, - }; - - it("uses CS API with url transport", async () => { - await delegateDelayedLeave({ ...defaultParams, transport: urlTransport }); - expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled(); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); - - it("does not fall back to OpenID with url transport", async () => { - client._unstable_delegateDelayedLeave.mockRejectedValueOnce( - unsupportedError, - ); - await expect( - delegateDelayedLeave({ ...defaultParams, transport: urlTransport }), - ).rejects.toThrow(); - expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled(); - expect(openID.getSFUConfig).not.toHaveBeenCalled(); - }); - - it("uses OpenID with livekit_service_url transport", async () => { - await delegateDelayedLeave({ - ...defaultParams, - transport: serviceUrlTransport, - }); - expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled(); - expect(openID.getSFUConfig).toHaveBeenCalled(); - }); - - it("uses OpenID with url + livekit_service_url transport", async () => { - // Must match the flow that got the token, so the identities line up - await delegateDelayedLeave({ - ...defaultParams, - transport: urlAndServiceUrlTransport, - }); - expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled(); - expect(openID.getSFUConfig).toHaveBeenCalledWith( - expect.objectContaining({ - role: "publisher", - delayId: "delay123", - delayEndpointBaseUrl: client.baseUrl, - }), - ); - }); -}); diff --git a/src/livekit/auth/index.ts b/src/livekit/auth/index.ts deleted file mode 100644 index 292dac4d4..000000000 --- a/src/livekit/auth/index.ts +++ /dev/null @@ -1,253 +0,0 @@ -/* -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 { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; -import { type Logger } from "matrix-js-sdk/lib/logger"; - -import { ConnectionError, MatrixError } from "matrix-js-sdk"; -import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; -import * as openID from "./openID"; -import * as csApi from "./csApi"; -import { type SFUConfig } from "./types"; -import { NoMatrix2AuthorizationService } from "../../utils/errors"; - -export type { ClientOpenIDParts } from "./openID"; -export type { ClientGetTokenParts, ClientDelegationParts } from "./csApi"; -export type * from "./types"; - -/** - * 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") - ); -} - -/** - * Checks whether an error means the MSC4195 CS API cannot be used from here: - * the homeserver does not implement it, or the request never reached the - * homeserver because in widget mode the host does not expose the endpoint - * (MSC4533), which the widget API reports as a plain error rather than a - * Matrix error. A connection error is neither, as the endpoint may well exist. - * @param e The error to check. - */ -function isCsApiUnavailable(e: unknown): boolean { - if (isEndpointUnsupported(e)) return true; - return !(e instanceof MatrixError) && !(e instanceof ConnectionError); -} - -export interface GetSFUConfigParams { - /** - * The Matrix client. - */ - client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts; - /** - * Data identifying the local user's session membership. - */ - membership: CallMembershipIdentityParts; - /** - * The transport for which we wish to get an access token. - */ - transport: UnstableLivekitTransport; - /** - * The name of the homeserver to which the {@link transport} belongs. - */ - serverName: string; - /** - * The ID of the Matrix room in which the session takes place. - */ - roomId: string; - /** - * The ID of the MatrixRTC slot in which the session takes place. - */ - slotId: string; - /** - * Whether we want to publish or only subscribe on the {@link transport}. - */ - role: "publisher" | "subscriber"; - logger: Logger; -} - -/** - * Gets an {@link SFUConfig} appropriate for connecting to a given transport. - */ -export async function getSFUConfig({ - client, - membership, - transport, - serverName, - roomId, - slotId, - role, - logger, -}: GetSFUConfigParams): Promise { - const viaOpenID = async (serviceUrl: string): Promise => - await openID.getSFUConfig({ - client, - membership, - serviceUrl, - roomId, - slotId, - role, - logger, - }); - - const flow = chooseAuthFlow(transport, role); - if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl); - - try { - return await csApi.getSFUConfig({ - client, - membership, - url: flow.url, - serverName, - roomId, - slotId, - }); - } catch (e) { - // Publishers should never fall back. If a homeserver advertises a `url` - // but doesn't support MSC4195 itself, that's a misconfiguration which - // the admin should know about so they can prepare for the future - // deprecation of `livekit_service_url`. - const mayFallBack = - role === "subscriber" && "livekit_service_url" in transport; - if (mayFallBack && isCsApiUnavailable(e)) { - logger.warn( - `Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`, - ); - return await viaOpenID(transport.livekit_service_url); - } - if (isEndpointUnsupported(e)) { - logger.error( - `Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`, - ); - throw new NoMatrix2AuthorizationService(e as Error); - } - throw e; - } -} - -export interface DelegateDelayedLeaveParams { - /** - * The Matrix client. - */ - client: csApi.ClientDelegationParts & openID.ClientOpenIDParts; - /** - * Data identifying the local user's session membership. - */ - membership: CallMembershipIdentityParts; - /** - * The transport to which we are publishing and wish to delegate the delayed - * leave event. - */ - transport: UnstableLivekitTransport; - /** - * The ID of the Matrix room in which the session takes place. - */ - roomId: string; - /** - * The ID of the MatrixRTC slot in which the session takes place. - */ - slotId: string; - /** - * The delay ID of the leave event to be delegated. - */ - delayId: string; - logger: Logger; -} - -/** - * Delegates a delayed leave event to a given transport, so that the event will - * be sent on our behalf whenever we disconnect from the transport's SFU. - */ -export async function delegateDelayedLeave({ - client, - membership, - transport, - roomId, - slotId, - delayId, - logger, -}: DelegateDelayedLeaveParams): Promise { - // It's important that we use the same flow (CS API or OpenID) as whatever we - // would've used to get a JWT token for this transport, since the participant - // identity computed by the service needs to match. - const flow = chooseAuthFlow(transport, "publisher"); - if (flow.kind === "csApi") { - try { - await csApi.delegateDelayedLeave({ - client, - membership, - url: flow.url, - roomId, - slotId, - delayId, - }); - } catch (e) { - if (isEndpointUnsupported(e)) - logger.error( - "Server misconfigured: transport contains a 'url' field but server does not support the MSC4195 delegate_delayed_leave endpoint", - ); - throw e; - } - } else { - // This will technically cause the service to issue a new JWT token, but - // it's safe to discard. We're only interested in triggering delegation. - await openID.getSFUConfig({ - client, - membership, - serviceUrl: flow.serviceUrl, - roomId, - slotId, - role: "publisher", - delayEndpointBaseUrl: client.baseUrl, - delayId, - logger, - }); - } -} - -/** - * The flow with which the local member authenticates against a transport. - */ -export type AuthFlow = - | { kind: "csApi"; url: string } - | { kind: "openID"; serviceUrl: string }; - -/** - * Chooses between the MSC4195 CS API and the legacy OpenID flow against the - * transport's JWT service. - * - * Only the JWT service's `/sfu/get` endpoint assigns the legacy participant - * identity that clients predating MSC4195 expect from anyone advertising a - * membership state event. So a publisher keeps using the JWT service wherever - * the transport still offers one, even if it also has a `url`. Subscribers may - * use the CS API regardless, as nobody matches their identity. - * @param transport The transport to authenticate against. - * @param role Whether we want to publish or only subscribe on the transport. - */ -export function chooseAuthFlow( - transport: UnstableLivekitTransport, - role: "publisher" | "subscriber", -): AuthFlow { - if ("url" in transport) { - const useCsApi = - !("livekit_service_url" in transport) || role === "subscriber"; - if (useCsApi) return { kind: "csApi", url: transport.url }; - } - return { kind: "openID", serviceUrl: transport.livekit_service_url }; -} diff --git a/src/livekit/auth/openID.test.ts b/src/livekit/auth/openID.test.ts deleted file mode 100644 index 7498b8fe1..000000000 --- a/src/livekit/auth/openID.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* -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 { MatrixError } from "matrix-js-sdk"; - -import { - getSFUConfig, - type GetSFUConfigParams, - type ClientOpenIDParts, -} from "./openID"; -import { testJWTToken } from "../../utils/test-fixtures"; -import { ownMemberMock } from "../../utils/test"; -import { FailToGetOpenIdToken } from "../../utils/errors"; -import { logger } from "matrix-js-sdk/lib/logger"; - -const serviceUrl = "https://sfu.example.org"; - -const delayParams = { - delayEndpointBaseUrl: "https://matrix.homeserverserver.org", - delayId: "mock_delay_id", -}; - -describe("getSFUConfig", () => { - let matrixClient: MockedObject; - let params: GetSFUConfigParams; - beforeEach(() => { - fetchMock.catch(404); - matrixClient = { getOpenIdToken: vitest.fn() }; - params = { - client: matrixClient, - membership: ownMemberMock, - serviceUrl: "https://sfu.example.org", - roomId: "!example_room_id", - slotId: "m.call#ROOM", - role: "publisher", - logger, - }; - }); - afterEach(async () => { - void (await fetchMock.flush()); - vitest.clearAllMocks(); - fetchMock.reset(); - }); - - it("should handle fetching a token", async () => { - fetchMock.post("https://sfu.example.org/sfu/get", () => { - return { - status: 200, - body: { url: serviceUrl, jwt: testJWTToken }, - }; - }); - const config = await getSFUConfig(params); - expect(config).toEqual({ - jwt: testJWTToken, - url: serviceUrl, - livekitIdentity: "@me:example.org:ABCDEF", - livekitAlias: "!example_room_id", - }); - }); - - it("should fail if the SFU errors", async () => { - fetchMock.post("https://sfu.example.org/sfu/get", () => { - return { - status: 500, - body: { - errcode: "M_LOOKUP_FAILED", - error: "Failed to look up user info from homeserver", - }, - }; - }); - try { - await getSFUConfig(params); - } catch (ex: unknown) { - expect(ex).toBeInstanceOf(FailToGetOpenIdToken); - expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError); - const mxError = (ex as Error).cause as MatrixError; - expect(mxError.message).toEqual( - "MatrixError: [500] Failed to look up user info from homeserver", - ); - - return; - } - expect.fail("Expected test to throw;"); - }); - - it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => { - let callCount = 0; - - fetchMock.post( - "https://sfu.example.org/sfu/get", - (url, opts) => { - callCount++; - const body = JSON.parse(opts.body as string); - - // First call: check if it has delay parts and return 400 - if (callCount === 1) { - expect(body).toHaveProperty("delay_id", "mock_delay_id"); - return { - status: 400, - body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" }, - }; - } - - // Second call: check if delay parts were stripped and return success - expect(body).not.toHaveProperty("delay_id"); - expect(body).not.toHaveProperty("delay_timeout"); - expect(body).not.toHaveProperty("delay_cs_api_url"); - - return { - status: 200, - body: { url: serviceUrl, jwt: testJWTToken }, - }; - }, - { overwriteRoutes: true }, - ); - - // Note: Assuming getSFUConfig eventually calls getLiveKitJWT - const config = await getSFUConfig({ ...params, ...delayParams }); - - expect(config.jwt).toBe(testJWTToken); - expect(callCount).toBe(2); - }); - - it("should successfully send delay parameters to the JWT service legacy endpoint", async () => { - fetchMock.post( - "https://sfu.example.org/sfu/get", - (url, opts) => { - const body = JSON.parse(opts.body as string); - - // Verify, that the request contains the expected delay parameters - if ( - body.delay_id === "mock_delay_id" && - body.delay_timeout === 3600000 && - body.delay_cs_api_url === "https://matrix.homeserverserver.org" - ) { - return { - status: 200, - body: { url: serviceUrl, jwt: testJWTToken }, - }; - } - return { - status: 400, - body: { error: "Missing expected delay params" }, - }; - }, - { overwriteRoutes: true }, - ); - - const config = await getSFUConfig({ ...params, ...delayParams }); - - // Prüfe das Ergebnis - expect(config).toMatchObject({ - jwt: testJWTToken, - url: serviceUrl, - }); - }); - - it("should use the default endpoint when subscribing", async () => { - fetchMock.post("https://sfu.example.org/get_token", (url, opts) => { - const body = JSON.parse(opts.body as string); - expect(body).toMatchObject({ - room_id: "!example_room_id", - slot_id: "m.call#ROOM", - member: { - id: ownMemberMock.memberId, - claimed_user_id: ownMemberMock.userId, - claimed_device_id: ownMemberMock.deviceId, - }, - }); - return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } }; - }); - const config = await getSFUConfig({ ...params, role: "subscriber" }); - expect(config.jwt).toBe(testJWTToken); - expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false); - }); - - it("should fail when subscribing if the default endpoint is missing", async () => { - // Nothing but the catch-all 404 answers the default endpoint. Subscribers - // must not fall back to the legacy endpoint, which would hand them a - // legacy identity. - await expect( - getSFUConfig({ ...params, role: "subscriber" }), - ).rejects.toThrow(FailToGetOpenIdToken); - expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true); - expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false); - }); - - 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: serviceUrl, jwt: testJWTToken }, - }; - }); - const config = await getSFUConfig({ ...params, ...delayParams }); - expect(config).toEqual({ - jwt: testJWTToken, - url: serviceUrl, - livekitIdentity: "@me:example.org:ABCDEF", - livekitAlias: "!example_room_id", - }); - }); -}); diff --git a/src/livekit/auth/openID.ts b/src/livekit/auth/openID.ts deleted file mode 100644 index 4479d4261..000000000 --- a/src/livekit/auth/openID.ts +++ /dev/null @@ -1,262 +0,0 @@ -/* -Copyright 2023, 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE in the repository root for full details. -*/ - -import { - type IOpenIDToken, - type MatrixClient, - parseErrorResponse, -} from "matrix-js-sdk"; -import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; -import { type Logger } from "matrix-js-sdk/lib/logger"; - -import { FailToGetOpenIdToken } from "../../utils/errors"; -import { doNetworkOperationWithRetry } from "../../utils/matrix"; -import { Config } from "../../config/Config"; -import { extractFullConfigFromToken, type SFUConfig } from "./types"; - -// The bits we need from MatrixClient -export type ClientOpenIDParts = Pick; - -export interface GetSFUConfigParams { - client: ClientOpenIDParts; - /** - * Data identifying the local user's session membership. - */ - membership: CallMembershipIdentityParts; - /** - * The base URL of the LiveKit JWT service. - */ - serviceUrl: string; - /** - * The ID of the Matrix room in which the session takes place. - */ - roomId: string; - /** - * The ID of the MatrixRTC slot in which the session takes place. - */ - slotId: string; - /** - * Whether we want to publish or only subscribe on the {@link transport}. - */ - role: "publisher" | "subscriber"; - /** - * The base URL of the Matrix homeserver, for delayed leave delegation. - */ - delayEndpointBaseUrl?: string; - /** - * The delay ID of the leave event to be delegated to the JWT service. - */ - delayId?: string; - logger: Logger; -} - -/** - * Gets an {@link SFUConfig} appropriate for connecting to the SFU behind a - * given service URL, using the legacy OpenID flow. - * @throws {@link FailToGetOpenIdToken} - */ -export async function getSFUConfig({ - client, - membership, - serviceUrl, - roomId, - slotId, - role, - delayEndpointBaseUrl, - delayId, - logger, -}: GetSFUConfigParams): Promise { - let openIdToken: IOpenIDToken; - try { - openIdToken = await doNetworkOperationWithRetry(async () => - client.getOpenIdToken(), - ); - } catch (error) { - throw new FailToGetOpenIdToken( - error instanceof Error ? error : new Error("Unknown error"), - ); - } - logger.debug("Got openID token", openIdToken); - let sfuConfig: { url: string; jwt: string } | undefined; - - let endpoint: "default" | "legacy"; - if (role === "publisher") { - // When publishing a legacy transport (one with a `livekit_service_url`), - // subscribers will expect our participant identity to use the legacy - // `@user_id:device_id` format. Only the legacy JWT service endpoint assigns - // identities in this format, so we must use it. - endpoint = "legacy"; - } else { - // Use the default endpoint as it affords us slightly better metadata - // protection (pseudonymous participant identities). - endpoint = "default"; - } - - try { - logger.info( - `Trying to get JWT with ${endpoint} endpoint for focus ${serviceUrl}...`, - ); - sfuConfig = await ( - endpoint === "legacy" ? getLiveKitJWTLegacy : getLiveKitJWT - )( - membership, - serviceUrl, - roomId, - slotId, - openIdToken, - delayEndpointBaseUrl, - delayId, - ); - logger.info(`Got JWT from call's active focus URL.`); - return extractFullConfigFromToken(sfuConfig); - } catch (e) { - logger.error(`Failed fetching jwt with ${endpoint} endpoint:`, e); - throw new FailToGetOpenIdToken( - e instanceof Error ? e : new Error(`Unknown error ${e}`), - ); - } -} - -/** - * Gets a JWT token appropriate for connecting to the SFU behind a given service - * URL, using the legacy `/sfu/get` endpoint, which assigns us a legacy - * (`:`) participant identity. - */ -async function getLiveKitJWTLegacy( - membership: CallMembershipIdentityParts, - livekitServiceURL: string, - matrixRoomId: string, - _slotId: string, // Unused - the legacy endpoint is from before slots existed. - openIDToken: IOpenIDToken, - delayEndpointBaseUrl?: string, - delayId?: string, -): Promise<{ url: string; jwt: string }> { - interface IDelayParams { - delay_id?: string; - delay_timeout?: number; - delay_cs_api_url?: string; - } - let bodyDalayParts: IDelayParams = {}; - // Also check for empty string - if (delayId && delayEndpointBaseUrl) { - bodyDalayParts = { - delay_id: delayId, - delay_timeout: - Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms, - delay_cs_api_url: delayEndpointBaseUrl, - }; - } - - const makeRequest = async (delayParts: IDelayParams): Promise => { - return await fetch(livekitServiceURL + "/sfu/get", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - // The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias. - // However, the livekit room alias is provided as part of the JWT payload. - room: matrixRoomId, - openid_token: openIDToken, - device_id: membership.deviceId, - ...delayParts, - }), - }); - }; - - const res = await doNetworkOperationWithRetry(async () => { - let response = await makeRequest(bodyDalayParts); - - // Old service compatibility check - const oldServiceDoesNotSupportDelayParts = - response.status === 400 && Object.keys(bodyDalayParts).length > 0; - // If http status 400 with M_BAD_JSON and we sent delay parts, retry without them - if (oldServiceDoesNotSupportDelayParts) { - try { - const errorBody = await response.json(); - if (errorBody.errcode === "M_BAD_JSON") { - response = await makeRequest({}); - } - } catch { - // If we can't parse the error, treat as real error - } - } - - return response; - }); - - if (!res.ok) { - throw parseErrorResponse(res, await res.text()); - } - return await res.json(); -} - -class NotSupportedError extends Error { - public constructor(message: string) { - super(message); - this.name = "NotSupported"; - } -} - -/** - * Gets a JWT token appropriate for connecting to the SFU behind a given service - * URL, using the default `/get_token` endpoint, which assigns us a hashed - * (pseudonymised) participant identity. - */ -async function getLiveKitJWT( - membership: CallMembershipIdentityParts, - livekitServiceURL: string, - matrixRoomId: string, - slotId: string, - openIDToken: IOpenIDToken, - delayEndpointBaseUrl?: string, - delayId?: string, -): Promise<{ url: string; jwt: string }> { - const { userId, deviceId, memberId } = membership; - - const body = { - room_id: matrixRoomId, - slot_id: slotId, - openid_token: openIDToken, - member: { - id: memberId, - claimed_user_id: userId, - claimed_device_id: deviceId, - }, - }; - - let bodyDalayParts = {}; - // Also check for empty string - if (delayId && delayEndpointBaseUrl) { - bodyDalayParts = { - delay_id: delayId, - delay_timeout: - Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms, - delay_cs_api_url: delayEndpointBaseUrl, - }; - } - - const res = await doNetworkOperationWithRetry(async () => { - return await fetch(livekitServiceURL + "/get_token", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ ...body, ...bodyDalayParts }), - }); - }); - - if (!res.ok) { - const msg = "SFU Config fetch failed with status code " + res.status; - if (res.status === 404) { - throw new NotSupportedError(msg); - } else { - throw parseErrorResponse(res, await res.text()); - } - } - return await res.json(); -} diff --git a/src/livekit/auth/types.ts b/src/livekit/auth/types.ts deleted file mode 100644 index 409d88a6b..000000000 --- a/src/livekit/auth/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* -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 { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; - -/** - * Data identifying a transport and the homeserver to which it belongs, so - * consumers know how to reach it. - */ -export interface TransportLocator { - transport: UnstableLivekitTransport; - serverName: string; -} - -/** - * 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/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index b43c6ff0d..a2bbd9380 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -72,8 +72,6 @@ import { } from "@element-hq/matrixrtc-sdk"; import { useBehavior } from "../useBehavior"; import { type ViewModel } from "../state/ViewModel.ts"; -import { getSFUConfig } from "../livekit/auth/index.ts"; -import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; /** * The state of MatrixRTC's media key rotation. diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 62bf1507c..89b4570dc 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -126,7 +126,6 @@ import { type RingingMediaViewModel, } from "../media/RingingMediaViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts"; -import { type TransportLocator } from "../../livekit/auth"; /** How a call captures and encodes the tracks it publishes. */ export interface CaptureSettings { diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index 9d533a64f..2824ec421 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -128,8 +128,6 @@ export function withCallViewModel(mode: MatrixRTCMode) { }; const room = mockMatrixRoom({ client: new (class extends EventEmitter { - public readonly baseUrl = "https://matrix.example.com"; - public getUserId(): string | undefined { return localRtcMember.userId; } diff --git a/src/state/CallViewModel/remoteMembers/ConnectionMap.ts b/src/state/CallViewModel/remoteMembers/ConnectionMap.ts deleted file mode 100644 index 586e8d49b..000000000 --- a/src/state/CallViewModel/remoteMembers/ConnectionMap.ts +++ /dev/null @@ -1,219 +0,0 @@ -/* -Copyright 2025-2026 Element Creations Ltd. -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. -*/ - -import { combineLatest, map, of, switchMap, tap } from "rxjs"; -import { type Logger } from "matrix-js-sdk/lib/logger"; -import { type RemoteParticipant } from "livekit-client"; -import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; - -import { type Behavior } from "../../Behavior.ts"; -import { type Connection } from "./Connection.ts"; -import { - Epoch, - mapEpoch, - type ObservableScope, -} from "../../ObservableScope.ts"; -import { generateItemsWithEpoch } from "../../../utils/observable.ts"; -import { type ConnectionFactory } from "./ConnectionFactory.ts"; -import { type TransportLocator } from "../../../livekit/auth/types.ts"; - -// Branded string type, so that only values returned by `keyFor` can be used -// as keys -export type TransportKey = string & { __transportKey: true }; - -/** - * Converts a {@link TransportLocator} to a key suitable for identifying it in a - * {@link Map} or {@link Set}. Locators with the same key share a connection. - */ -export function keyFor({ - transport, - serverName, -}: TransportLocator): TransportKey { - // The server name only takes part in authentication for a transport with a - // `url`, where our homeserver asks that server for the token. A JWT service - // is reached directly, so members of several homeservers advertising the - // same one must share a connection rather than each get their own. - const hasUrl = "url" in transport; - // Serialize the fields in a consistent order - return JSON.stringify([ - hasUrl ? transport.url : null, - "livekit_service_url" in transport ? transport.livekit_service_url : null, - hasUrl ? serverName : null, - ]) as TransportKey; -} - -function removeDuplicateTransports( - transports: TransportLocator[], -): TransportLocator[] { - // The first locator wins, so the local one stays first in the list - const map = new Map(); - for (const transport of transports) { - const key = keyFor(transport); - if (!map.has(key)) map.set(key, transport); - } - return [...map.values()]; -} - -export class ConnectionMap { - private readonly map = new Map< - TransportKey, - { connection: Connection; participants: RemoteParticipant[] } - >(); - - public constructor(private readonly logger?: Logger) {} - - public add(connection: Connection, participants: RemoteParticipant[]): void { - const key = keyFor(connection); - const existing = this.map.get(key); - if (!existing) { - this.map.set(key, { connection, participants }); - } else { - // Transports are deduplicated by key upstream, so this should never - // happen; if it does, members may be matched against the wrong room. - this.logger?.warn( - `Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`, - ); - existing.participants.push(...participants); - } - } - - public getConnections(): Connection[] { - return Array.from(this.map.values()).map(({ connection }) => connection); - } - - public getConnectionForTransport( - transport: TransportLocator, - ): Connection | null { - return this.map.get(keyFor(transport))?.connection ?? null; - } - - public getParticipantsForTransport( - transport: TransportLocator, - ): RemoteParticipant[] { - const existing = this.map.get(keyFor(transport)); - if (existing) { - return existing.participants; - } - return []; - } -} - -interface Props { - scope: ObservableScope; - connectionFactory: ConnectionFactory; - localTransport: TransportLocator; - remoteTransports$: Behavior>; - - logger: Logger; - ownMembershipIdentity: CallMembershipIdentityParts; -} - -/** - * Create a dynamic map of connections and their associated backend participants. - * @param props - Configuration object - * @param props.scope - The observable scope used by this object - * @param props.connectionFactory - Used to create new connections - * @param props.localTransport - The transport to publish local media on. (deduplicated with remoteTransports$) - * @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport) - * @param props.ownMembershipIdentity - The own membership identity to use. - * @param props.logger - The logger to use. - */ -export function createConnectionMap$({ - scope, - connectionFactory, - localTransport, - remoteTransports$, - logger: parentLogger, - ownMembershipIdentity, -}: Props): Behavior> { - const logger = parentLogger.getChild("[ConnectionMap]"); - // TODO logger: only construct one logger from the client and make it compatible via a EC specific sing - - /** - * All transports currently represented in the connection map. This list does - * not include duplicate transports. - */ - const localAndRemoteTransports$: Behavior> = - scope.behavior( - remoteTransports$.pipe( - mapEpoch((remoteTransports) => - removeDuplicateTransports([localTransport, ...remoteTransports]), - ), - tap((transports) => - logger.debug("localAndRemoteTransports$ = ", transports), - ), - ), - ); - - const localTransportKey = keyFor(localTransport); - - /** - * Connections for each transport in use by one or more session members. - */ - const connections$ = scope.behavior( - localAndRemoteTransports$.pipe( - generateItemsWithEpoch( - "ConnectionMap connections$", - function* (transports) { - for (const transport of transports) { - const role = - keyFor(transport) === localTransportKey - ? ("publisher" as const) - : ("subscriber" as const); - yield { - // The locator is an object, so a stable key stands in for it - keys: [role, keyFor(transport)], - data: transport, - }; - } - }, - (scope, locator$, role, _key) => { - const { transport, serverName } = locator$.value; - const connection = connectionFactory.createConnection( - scope, - role, - transport, - serverName, - ownMembershipIdentity, - logger, - ); - // Start the connection immediately - // Use connection state to track connection progress - void connection.start(); - // TODO subscribe to connection state to retry or log issues? - return connection; - }, - ), - ), - ); - - return scope.behavior( - connections$.pipe( - switchMap(({ value: connections, epoch }) => { - if (connections.length === 0) - return of(new Epoch(new ConnectionMap(), epoch)); - - return combineLatest( - // Map the connections to list of Observable<{connection, participants}> - connections.map((connection) => - connection.remoteParticipants$.pipe( - map((participants) => ({ connection, participants })), - ), - ), - // Collect into a single ConnectionMap - (...list) => { - const map = new ConnectionMap(logger); - for (const item of list) - map.add(item.connection, item.participants); - return new Epoch(map, epoch); - }, - ); - }), - ), - ); -} diff --git a/src/state/media/MemberMediaViewModel.ts b/src/state/media/MemberMediaViewModel.ts index 54500ae4e..98e64e6ad 100644 --- a/src/state/media/MemberMediaViewModel.ts +++ b/src/state/media/MemberMediaViewModel.ts @@ -34,7 +34,6 @@ import { import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel"; import { type UserMediaViewModel } from "./UserMediaViewModel"; import { type ScreenShareViewModel } from "./ScreenShareViewModel"; -import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; /** How often a view model reads a track's RTP statistics while it needs them. */ export const statsIntervalMs = 1000;