Let the publishing Connection authenticate itself

getLocalTransport no longer authenticates with the SFU up front and hands
the resulting config to the connection. Instead the ConnectionManager tags
the connection whose transport equals the local one as the publisher, and
every Connection fetches its own SFU config for its role.

The local member now only joins the MatrixRTC session once its connection
is authenticated, so that the LiveKit room exists before remote members
try to get a subscriber token for it. Subscribers authenticating with the
OpenID flow lack permission to create the room and would otherwise fail
without retrying, causing a cascade of leaves and rejoins at the start of
a call.
This commit is contained in:
Robin
2026-10-12 10:22:33 +02:00
committed by Johannes Marbach
parent 17c3223883
commit 24b6a632dd
13 changed files with 149 additions and 404 deletions
+3 -13
View File
@@ -112,10 +112,7 @@ import {
enterRTCSession, enterRTCSession,
TransportState, TransportState,
} from "./localMember/LocalMember.ts"; } from "./localMember/LocalMember.ts";
import { import { getLocalTransport } from "./localMember/LocalTransport.ts";
getLocalTransport,
type LocalTransport,
} from "./localMember/LocalTransport.ts";
import { import {
createKeyRotationSuppressed$, createKeyRotationSuppressed$,
createMemberships$, createMemberships$,
@@ -216,7 +213,7 @@ export interface CallViewModelOptions {
*/ */
windowSize$: Behavior<{ width: number; height: number }>; windowSize$: Behavior<{ width: number; height: number }>;
/** Optional value overriding the local transport, for testing purposes. */ /** Optional value overriding the local transport, for testing purposes. */
localTransport?: LocalTransport; localTransport?: LivekitTransport;
/** Optional value overriding the connection factory, for testing purposes. */ /** Optional value overriding the connection factory, for testing purposes. */
connectionFactory?: ConnectionFactory; connectionFactory?: ConnectionFactory;
/** The version & compatibility mode of MatrixRTC that we should use. */ /** The version & compatibility mode of MatrixRTC that we should use. */
@@ -565,14 +562,7 @@ export function createCallViewModel$(
const localTransport$ = options.localTransport const localTransport$ = options.localTransport
? constant(options.localTransport) ? constant(options.localTransport)
: from( : from(getLocalTransport(client));
getLocalTransport({
ownMembershipIdentity,
client,
roomId: matrixRoom.roomId,
slotId: matrixRTCSession.slotId,
}),
);
const connectionFactory = const connectionFactory =
options.connectionFactory ?? options.connectionFactory ??
@@ -30,7 +30,6 @@ import {
type CallViewModelOptions, type CallViewModelOptions,
} from "./CallViewModel"; } from "./CallViewModel";
import { import {
exampleSfuConfig,
exampleTransport, exampleTransport,
mockConfig, mockConfig,
MockConnection, MockConnection,
@@ -209,24 +208,21 @@ export function withCallViewModel(mode: MatrixRTCMode) {
livekitRoomFactory, livekitRoomFactory,
connectionState$, connectionState$,
windowSize$, windowSize$,
localTransport: { localTransport: exampleTransport,
transport: exampleTransport,
sfuConfig: exampleSfuConfig,
},
connectionFactory: { connectionFactory: {
createConnection( createConnection(
scope, scope,
role,
transport, transport,
ownMembershipIdentity, ownMembershipIdentity,
logger, logger,
sfuConfig,
) { ) {
return new MockConnection( return new MockConnection(
{ {
scope, scope,
role,
transport, transport,
ownMembershipIdentity, ownMembershipIdentity,
existingSFUConfig: sfuConfig,
client: room.client, client: room.client,
roomId: room.roomId, roomId: room.roomId,
slotId: "m.call#ROOM", slotId: "m.call#ROOM",
@@ -65,7 +65,6 @@ import {
import { type Publisher } from "./Publisher"; import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget"; import { initializeWidget } from "../../../widget";
import { nullHostBridge } from "../../../HostBridge"; import { nullHostBridge } from "../../../HostBridge";
import { type LocalTransport } from "./LocalTransport";
import * as livekitAuth from "../../../livekit/auth"; import * as livekitAuth from "../../../livekit/auth";
initializeWidget(); initializeWidget();
@@ -221,7 +220,10 @@ describe("LocalMembership", () => {
processor: undefined, processor: undefined,
}), }),
logger: logger, logger: logger,
createPublisherFactory: vi.fn(), createPublisherFactory: vi.fn().mockReturnValue({
destroy: vi.fn(),
createAndSetupTracks: vi.fn().mockResolvedValue(undefined),
}),
joinMatrixRTC: async (): Promise<void> => {}, joinMatrixRTC: async (): Promise<void> => {},
homeserverConnected: { homeserverConnected: {
combined$: constant<[boolean, HomeserverDisconnectReason | null]>([ combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
@@ -254,6 +256,7 @@ describe("LocalMembership", () => {
afterEach(async () => { afterEach(async () => {
void (await fetchMock.flush()); void (await fetchMock.flush());
fetchMock.reset(); fetchMock.reset();
vi.clearAllMocks();
}); });
it("throws error on missing RTC config error", () => { it("throws error on missing RTC config error", () => {
@@ -322,20 +325,10 @@ describe("LocalMembership", () => {
scope.end(); scope.end();
}); });
const mockTransportConfig = { const mockTransport = {
livekit_service_url: "a", livekit_service_url: "a",
} as LivekitTransport; } as LivekitTransport;
const mockTransport = {
transport: mockTransportConfig,
sfuConfig: {
jwt: "foo",
livekitAlias: "bar",
livekitIdentity: "baz",
url: "bro",
},
} as LocalTransport;
const connectionTransportAConnected = { const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
localParticipant: { localParticipant: {
@@ -347,7 +340,7 @@ describe("LocalMembership", () => {
state: "authenticated", state: "authenticated",
livekitState: LivekitConnectionState.Connected, livekitState: LivekitConnectionState.Connected,
}), }),
transport: mockTransportConfig, transport: mockTransport,
} as Connection; } as Connection;
const connectionTransportAConnecting = { const connectionTransportAConnecting = {
...connectionTransportAConnected, ...connectionTransportAConnected,
@@ -381,13 +374,13 @@ describe("LocalMembership", () => {
if (delegationUrl !== null) if (delegationUrl !== null)
fetchMock.post(delegationUrl, () => ({ status: 401, body: {} })); fetchMock.post(delegationUrl, () => ({ status: 401, body: {} }));
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionManager: {
connectionManagerData$: constant( connectionManagerData$: constant(new Epoch(connectionManagerData)),
new Epoch(new ConnectionManagerData()),
),
}, },
joinMatrixRTC, joinMatrixRTC,
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
@@ -399,7 +392,7 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// Joins with timings appropriate for the level of delegation support // Joins with timings appropriate for the level of delegation support
expect(joinMatrixRTC).toHaveBeenCalledWith( expect(joinMatrixRTC).toHaveBeenCalledWith(
mockTransportConfig, mockTransport,
delayedLeaveTimings, delayedLeaveTimings,
); );
@@ -491,7 +484,7 @@ describe("LocalMembership", () => {
it("tracks livekit state correctly", async () => { it("tracks livekit state correctly", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData(); const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new Subject<LocalTransport>(); const localTransport$ = new Subject<LivekitTransport>();
const connectionManagerData$ = new BehaviorSubject( const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData), new Epoch(connectionManagerData),
@@ -570,7 +563,7 @@ describe("LocalMembership", () => {
}); });
( (
connectionManagerData2.getConnectionForTransport(mockTransportConfig)! connectionManagerData2.getConnectionForTransport(mockTransport)!
.state$ as BehaviorSubject<ConnectionState> .state$ as BehaviorSubject<ConnectionState>
).next({ ).next({
state: "authenticated", state: "authenticated",
@@ -891,7 +884,7 @@ describe("LocalMembership", () => {
state: "authenticated", state: "authenticated",
livekitState: LivekitConnectionState.Connected, livekitState: LivekitConnectionState.Connected,
}), }),
transport: mockTransportConfig, transport: mockTransport,
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({ localParticipant: mockLocalParticipant({
isScreenShareEnabled: false, isScreenShareEnabled: false,
@@ -78,7 +78,6 @@ import {
type Connection, type Connection,
} from "../remoteMembers/Connection.ts"; } from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts";
import { type ClientOpenIDParts, getSFUConfig } from "../../../livekit/auth"; import { type ClientOpenIDParts, getSFUConfig } from "../../../livekit/auth";
export enum TransportState { export enum TransportState {
@@ -148,7 +147,7 @@ interface Props {
homeserverConnected: HomeserverConnected; homeserverConnected: HomeserverConnected;
roomId: string; roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
localTransport$: Observable<LocalTransport>; localTransport$: Observable<LivekitTransport>;
client: ClientOpenIDParts; client: ClientOpenIDParts;
matrixRTCSession: Pick< matrixRTCSession: Pick<
MatrixRTCSession, MatrixRTCSession,
@@ -307,7 +306,7 @@ export const createLocalMembership$ = ({
// to whether delayed event delegation is supported // to whether delayed event delegation is supported
const joinParams$ = scope.behavior( const joinParams$ = scope.behavior(
localTransport$.pipe( localTransport$.pipe(
switchMap(async ({ transport }) => { switchMap(async (transport) => {
const transportSupportsDelegation = checkDelegationSupport( const transportSupportsDelegation = checkDelegationSupport(
transport.livekit_service_url + "/delegate_delayed_leave", transport.livekit_service_url + "/delegate_delayed_leave",
`transport ${transport.livekit_service_url}`, `transport ${transport.livekit_service_url}`,
@@ -329,7 +328,7 @@ export const createLocalMembership$ = ({
connectionManager.connectionManagerData$, connectionManager.connectionManagerData$,
localTransport$, localTransport$,
]).pipe( ]).pipe(
map(([{ value: connectionData }, { transport }]) => map(([{ value: connectionData }, transport]) =>
connectionData.getConnectionForTransport(transport), connectionData.getConnectionForTransport(transport),
), ),
tap((connection) => { tap((connection) => {
@@ -480,6 +479,10 @@ export const createLocalMembership$ = ({
), ),
); );
const authenticated$ = scope.behavior(
localConnectionState$.pipe(map(({ state }) => state === "authenticated")),
);
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior( const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
combineLatest([ combineLatest([
localConnectionState$, localConnectionState$,
@@ -664,34 +667,38 @@ export const createLocalMembership$ = ({
// Join and leave the session as needed // Join and leave the session as needed
scope.reconcile( scope.reconcile(
scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])), scope.behavior(
async ([joinParams, shouldConnect]) => { combineLatest([joinParams$, joinAndPublishRequested$, authenticated$]),
if (!joinParams) return; ),
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration. async ([joinParams, shouldConnect, authenticated]) => {
if (!shouldConnect) return;
const sessionConfig = Config.get().matrix_rtc_session; const sessionConfig = Config.get().matrix_rtc_session;
try { // Only join once authenticated, to ensure the LiveKit room exists first.
joinMatrixRTC( // Otherwise, subscribers authenticating with the OpenID flow may fail to
joinParams.transport, // join (they lack permission to create the room) and never retry.
joinParams.delegationSupported if (joinParams && shouldConnect && authenticated) {
? sessionConfig.delegated_delayed_leave
: sessionConfig.delayed_leave,
);
} catch (error) {
logger.error("Error entering RTC session", error);
if (error instanceof Error)
setMatrixError(new MembershipManagerError(error));
}
return Promise.resolve(async (): Promise<void> => {
try { try {
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session! joinMatrixRTC(
await matrixRTCSession.leave(1000); joinParams.transport,
} catch (e) { joinParams.delegationSupported
logger.error("Error leaving RTC session", e); ? sessionConfig.delegated_delayed_leave
: sessionConfig.delayed_leave,
);
} catch (error) {
logger.error("Error entering RTC session", error);
if (error instanceof Error)
setMatrixError(new MembershipManagerError(error));
} }
});
// Clean-up callback to leave the session
return Promise.resolve(async (): Promise<void> => {
try {
await matrixRTCSession.leave(1000);
} catch (e) {
logger.error("Error leaving RTC session", e);
}
});
}
}, },
); );
@@ -16,125 +16,51 @@ import {
} from "vitest"; } from "vitest";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { mockConfig, ownMemberMock } from "../../../utils/test"; import { mockConfig } from "../../../utils/test";
import { getLocalTransport } from "./LocalTransport"; import { getLocalTransport } from "./LocalTransport";
import { import { MatrixRTCTransportMissingError } from "../../../utils/errors";
MatrixRTCTransportMissingError,
FailToGetOpenIdToken,
} from "../../../utils/errors";
import * as livekitAuth from "../../../livekit/auth";
import { customLivekitUrl } from "../../../settings/settings"; import { customLivekitUrl } from "../../../settings/settings";
import { testJWTToken } from "../../../utils/test-fixtures"; import { type MatrixClient } from "matrix-js-sdk";
describe("LocalTransport", () => { describe("LocalTransport", () => {
const openIdResponse: livekitAuth.SFUConfig = {
url: "https://lk.example.org",
jwt: testJWTToken,
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
};
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("throws if config is missing", async () => { it("throws if config is missing", async () => {
await expect( await expect(
getLocalTransport({ getLocalTransport({
roomId: "!room:example.org", _unstable_getRTCTransports: async () => Promise.resolve([]),
client: { getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
slotId: "m.call#ROOM",
}), }),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org")); ).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
}); });
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(livekitAuth, "getSFUConfig").mockRejectedValue(
new FailToGetOpenIdToken(new Error("no openid")),
);
await expect(
getLocalTransport({
roomId: "!example_room_id",
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
slotId: "m.call#ROOM",
}),
).rejects.toThrow(new FailToGetOpenIdToken(new Error("no openid")));
});
it("returns preferred transport", async () => { it("returns preferred transport", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch // Use config so transport discovery succeeds
mockConfig({ mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
vi.spyOn(livekitAuth, "getSFUConfig").mockResolvedValue({
url: "https://lk.example.org",
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
});
expect( expect(
await getLocalTransport({ await getLocalTransport({
roomId: "!room:example.org", _unstable_getRTCTransports: async () => Promise.resolve([]),
client: { getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getOpenIdToken: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
slotId: "m.call#ROOM",
}), }),
).toStrictEqual({ ).toStrictEqual({
transport: { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit",
},
sfuConfig: {
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@alice:example.org:DEVICE",
url: "https://lk.example.org",
},
}); });
}); });
type LocalTransportProps = Parameters<typeof getLocalTransport>[0];
describe("transport configuration mechanisms", () => { describe("transport configuration mechanisms", () => {
let localTransportOpts: LocalTransportProps & { let client: MockedObject<
client: MockedObject<LocalTransportProps["client"]>; Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">
}; >;
beforeEach(() => { beforeEach(() => {
mockConfig({}); mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue); customLivekitUrl.setValue(customLivekitUrl.defaultValue);
localTransportOpts = { client = {
ownMembershipIdentity: ownMemberMock, getDomain: vi.fn().mockReturnValue("example.org"),
roomId: "!example_room_id", _unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
slotId: "m.call#ROOM",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getOpenIdToken: vi.fn(),
},
}; };
}); });
@@ -146,86 +72,38 @@ describe("LocalTransport", () => {
mockConfig({ mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
vi.spyOn(livekitAuth, "getSFUConfig").mockResolvedValue(openIdResponse);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({ expect(await getLocalTransport(client)).toStrictEqual({
transport: { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit",
},
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
it("supports getting transport via user settings", async () => { it("supports getting transport via user settings", async () => {
customLivekitUrl.setValue("https://lk.example.org"); customLivekitUrl.setValue("https://lk.example.org");
vi.spyOn(livekitAuth, "getSFUConfig").mockResolvedValue(openIdResponse);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({ expect(await getLocalTransport(client)).toStrictEqual({
transport: { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit",
},
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
it("supports getting transport via backend", async () => { it("supports getting transport via backend", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([ client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" }, { type: "livekit", livekit_service_url: "https://lk.example.org" },
]); ]);
vi.spyOn(livekitAuth, "getSFUConfig").mockResolvedValue(openIdResponse);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({ expect(await getLocalTransport(client)).toStrictEqual({
transport: { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit",
},
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
it("fails fast if the openID request fails for backend config", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
vi.spyOn(livekitAuth, "getSFUConfig").mockRejectedValue(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
await expect(getLocalTransport(localTransportOpts)).rejects.toThrow(
expect.any(FailToGetOpenIdToken),
);
});
it("throws if no options are available", async () => { it("throws if no options are available", async () => {
await expect( await expect(
getLocalTransport({ getLocalTransport({
ownMembershipIdentity: ownMemberMock, getDomain: () => "example.org",
roomId: "!example_room_id", _unstable_getRTCTransports: async () => Promise.resolve([]),
slotId: "m.call#ROOM",
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
},
}), }),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org")); ).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
}); });
@@ -7,63 +7,24 @@ Please see LICENSE in the repository root for full details.
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts";
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
NoMatrix2AuthorizationService,
} from "../../../utils/errors.ts";
import {
getSFUConfig,
type SFUConfig,
type ClientOpenIDParts,
} from "../../../livekit/auth";
import { customLivekitUrl } from "../../../settings/settings.ts"; import { customLivekitUrl } from "../../../settings/settings.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts"; import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
interface Props {
ownMembershipIdentity: CallMembershipIdentityParts;
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
ClientOpenIDParts;
// Used by the jwt service to create the livekit room and compute the livekit alias.
roomId: string;
slotId: string;
}
export interface LocalTransport {
transport: LivekitTransport;
sfuConfig: SFUConfig;
}
export function isLocalTransport(
obj: LivekitTransport | LocalTransport,
): obj is LocalTransport {
return "transport" in obj && "sfuConfig" in obj;
}
/** /**
* Connects to the JWT service and determines the transport that the local member should use. * Determines the transport to advertise in our MatrixRTC membership and publish
* * media on.
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
* (which is expected for non sticky event based rtc member events)
* @returns The transport to advertise in our MatrixRTC membership and publish media on.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
export async function getLocalTransport({ export async function getLocalTransport(
ownMembershipIdentity, client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
client, ): Promise<LivekitTransport> {
roomId,
slotId,
}: Props): Promise<LocalTransport> {
const logger = rootLogger.getChild("[LocalTransport]");
const discovery = new RtcTransportAutoDiscovery({ const discovery = new RtcTransportAutoDiscovery({
client: client, client,
resolvedConfig: Config.get(), resolvedConfig: Config.get(),
logger: logger, logger: rootLogger.getChild("[LocalTransport]"),
}); });
const customUrl = customLivekitUrl.value$.value; const customUrl = customLivekitUrl.value$.value;
@@ -75,72 +36,5 @@ export async function getLocalTransport({
if (transport === null) if (transport === null)
throw new MatrixRTCTransportMissingError(client.getDomain() ?? ""); throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
try { return transport;
return await doOpenIdAndJWTFromUrl(
transport,
ownMembershipIdentity,
roomId,
slotId,
client,
logger,
);
} catch (e) {
logger.error(
`Failed to authenticate to transport ${transport.livekit_service_url}`,
e,
);
throw mapAuthErrorToUserFriendlyError(e);
}
}
/**
* Utility to ensure the user can authenticate with the SFU.
* We will call `getSFUConfig` once per transport here as it's our
* only mechanism of validation. This means we will also ask the
* homeserver for a OpenID token a few times. Since OpenID tokens are single
* use we don't want to risk any issues by re-using a token.
*
* @param transport The transport to authenticate with.
* @param membership The identity of the local member.
* @param roomId The room ID to use for the JWT.
* @param slotId The slot ID to use for the JWT.
* @param client The client to use for the OpenID token.
*
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
*/
async function doOpenIdAndJWTFromUrl(
transport: LivekitTransport,
membership: CallMembershipIdentityParts,
roomId: string,
slotId: string,
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & ClientOpenIDParts,
logger: Logger,
): Promise<LocalTransport> {
const sfuConfig = await getSFUConfig({
client,
membership,
serviceUrl: transport.livekit_service_url,
roomId,
slotId,
role: "publisher",
logger,
});
return {
transport,
sfuConfig,
};
}
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
if (
e instanceof FailToGetOpenIdToken ||
e instanceof NoMatrix2AuthorizationService
) {
// rethrow as is
return e;
}
// Catch others and rethrow as FailToGetOpenIdToken that has user friendly message.
return new FailToGetOpenIdToken(
e instanceof Error ? e : new Error(String(e)),
);
} }
@@ -115,6 +115,7 @@ function setupRemoteConnection(): Connection {
client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
slotId: SLOT_ID, slotId: SLOT_ID,
role: "subscriber",
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
@@ -159,6 +160,7 @@ describe("Start connection states", () => {
client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
slotId: SLOT_ID, slotId: SLOT_ID,
role: "subscriber",
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
@@ -177,6 +179,7 @@ describe("Start connection states", () => {
client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
slotId: SLOT_ID, slotId: SLOT_ID,
role: "subscriber",
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
@@ -230,6 +233,7 @@ describe("Start connection states", () => {
client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
slotId: SLOT_ID, slotId: SLOT_ID,
role: "subscriber",
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
@@ -296,6 +300,7 @@ describe("Start connection states", () => {
client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
slotId: SLOT_ID, slotId: SLOT_ID,
role: "subscriber",
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
@@ -555,6 +560,7 @@ describe("remote track logging", () => {
client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
slotId: SLOT_ID, slotId: SLOT_ID,
role: "subscriber",
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
@@ -45,13 +45,10 @@ import {
} from "../../../livekit/auth"; } from "../../../livekit/auth";
export interface ConnectionOpts { export interface ConnectionOpts {
/**
* For the local transport we already do know the jwt token and url. We can reuse it.
* On top the local transport will send additional data to the jwt server to use delayed event delegation.
*/
existingSFUConfig?: SFUConfig;
/** The identity parts to use on this connection */ /** The identity parts to use on this connection */
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
/** Whether we want to publish or only subscribe on this connection. */
role: "publisher" | "subscriber";
/** The media transport to connect to. */ /** The media transport to connect to. */
transport: LivekitTransport; transport: LivekitTransport;
/** The Matrix client to use for OpenID and SFU config requests. */ /** The Matrix client to use for OpenID and SFU config requests. */
@@ -92,6 +89,11 @@ export class Connection {
*/ */
public readonly state$: Behavior<ConnectionState> = this._state$; public readonly state$: Behavior<ConnectionState> = this._state$;
/**
* Whether we want to publish or only subscribe on this connection.
*/
public readonly role: "publisher" | "subscriber";
/** /**
* The media transport to connect to. * The media transport to connect to.
*/ */
@@ -130,7 +132,6 @@ export class Connection {
private readonly slotId: string; private readonly slotId: string;
private readonly logger: Logger; private readonly logger: Logger;
private readonly ownMembershipIdentity: CallMembershipIdentityParts; private readonly ownMembershipIdentity: CallMembershipIdentityParts;
private readonly existingSFUConfig?: SFUConfig;
/** /**
* Creates a new connection to a matrix RTC LiveKit backend. * Creates a new connection to a matrix RTC LiveKit backend.
* *
@@ -140,19 +141,17 @@ export class Connection {
*/ */
public constructor(opts: ConnectionOpts, logger: Logger) { public constructor(opts: ConnectionOpts, logger: Logger) {
this.ownMembershipIdentity = opts.ownMembershipIdentity; this.ownMembershipIdentity = opts.ownMembershipIdentity;
this.existingSFUConfig = opts.existingSFUConfig;
this.roomId = opts.roomId; this.roomId = opts.roomId;
this.slotId = opts.slotId; this.slotId = opts.slotId;
this.logger = logger.getChild( this.logger = logger.getChild(
`[Connection ${JSON.stringify(opts.transport)}]`, `[Connection ${JSON.stringify(opts.transport)}]`,
); );
this.logger.info( this.logger.debug(`constructor called`);
`constructor: roomId: ${this.roomId} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`, const { role, transport, client, scope } = opts;
);
const { transport, client, scope } = opts;
this.scope = scope; this.scope = scope;
this.livekitRoom = opts.livekitRoomFactory(); this.livekitRoom = opts.livekitRoomFactory();
this.role = role;
this.transport = transport; this.transport = transport;
this.client = client; this.client = client;
@@ -295,11 +294,7 @@ export class Connection {
this.stopped = false; this.stopped = false;
try { try {
this._state$.next({ state: "authenticating" }); this._state$.next({ state: "authenticating" });
// We should already have this information after creating the localTransport. const { url, jwt, livekitAlias } = await this.getSFUConfig();
// only call getSFUConfig for connections where we do not have a token yet. (existingJwtTokenData === undefined)
const { url, jwt, livekitAlias } =
this.existingSFUConfig ??
(await this.getSFUConfigForRemoteConnection());
this.logger.debug( this.logger.debug(
"Starting Connection - jwt: ", "Starting Connection - jwt: ",
jwt, jwt,
@@ -381,16 +376,14 @@ export class Connection {
} }
} }
protected async getSFUConfigForRemoteConnection(): Promise<SFUConfig> { protected async getSFUConfig(): 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 getSFUConfig({ return await getSFUConfig({
client: this.client, client: this.client,
membership: this.ownMembershipIdentity, membership: this.ownMembershipIdentity,
role: this.role,
serviceUrl: this.transport.livekit_service_url, serviceUrl: this.transport.livekit_service_url,
roomId: this.roomId, roomId: this.roomId,
slotId: this.slotId, slotId: this.slotId,
role: "subscriber",
logger: this.logger, logger: this.logger,
}); });
} }
@@ -35,16 +35,16 @@ import {
noiseSuppressionSetting, noiseSuppressionSetting,
autoGainControlSetting, autoGainControlSetting,
} from "../../../settings/settings.ts"; } from "../../../settings/settings.ts";
import { type ClientOpenIDParts, type SFUConfig } from "../../../livekit/auth"; import { type ClientOpenIDParts } from "../../../livekit/auth";
// TODO evaluate if this should be done like the Publisher Factory // TODO evaluate if this should be done like the Publisher Factory
export interface ConnectionFactory { export interface ConnectionFactory {
createConnection( createConnection(
scope: ObservableScope, scope: ObservableScope,
role: "publisher" | "subscriber",
transport: LivekitTransport, transport: LivekitTransport,
ownMembershipIdentity: CallMembershipIdentityParts, ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger, logger: Logger,
sfuConfig?: SFUConfig,
): Connection; ): Connection;
} }
@@ -102,16 +102,16 @@ export class ECConnectionFactory implements ConnectionFactory {
*/ */
public createConnection( public createConnection(
scope: ObservableScope, scope: ObservableScope,
role: "publisher" | "subscriber",
transport: LivekitTransport, transport: LivekitTransport,
ownMembershipIdentity: CallMembershipIdentityParts, ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger, logger: Logger,
sfuConfig?: SFUConfig,
): Connection { ): Connection {
return new Connection( return new Connection(
{ {
existingSFUConfig: sfuConfig,
roomId: this.roomId, roomId: this.roomId,
slotId: this.slotId, slotId: this.slotId,
role,
transport, transport,
client: this.client, client: this.client,
scope: scope, scope: scope,
@@ -47,7 +47,7 @@ beforeEach(() => {
vi.mocked(fakeConnectionFactory).createConnection = vi vi.mocked(fakeConnectionFactory).createConnection = vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(scope: ObservableScope, transport: LivekitTransport) => { (scope: ObservableScope, _role: unknown, transport: LivekitTransport) => {
const mockConnection = { const mockConnection = {
transport, transport,
remoteParticipants$: new BehaviorSubject([]), remoteParticipants$: new BehaviorSubject([]),
@@ -245,7 +245,11 @@ describe("connectionManagerData$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection = vi vi.mocked(fakeConnectionFactory).createConnection = vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(scope: ObservableScope, transport: LivekitTransport) => { (
scope: ObservableScope,
_role: unknown,
transport: LivekitTransport,
) => {
const fakeRemoteParticipants$ = new BehaviorSubject< const fakeRemoteParticipants$ = new BehaviorSubject<
RemoteParticipant[] RemoteParticipant[]
>([]); >([]);
@@ -27,11 +27,6 @@ import { Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { generateItemsWithEpoch } from "../../../utils/observable.ts"; import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
import {
isLocalTransport,
type LocalTransport,
} from "../localMember/LocalTransport.ts";
import { type SFUConfig } from "../../../livekit/auth";
export class ConnectionManagerData { export class ConnectionManagerData {
private readonly store: Map< private readonly store: Map<
@@ -87,7 +82,7 @@ export class ConnectionManagerData {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
connectionFactory: ConnectionFactory; connectionFactory: ConnectionFactory;
localTransport$: Observable<LocalTransport>; localTransport$: Observable<LivekitTransport>;
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>; remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger; logger: Logger;
@@ -147,17 +142,17 @@ export function createConnectionManager$({
* externally this is modified via `registerTransports()`. * externally this is modified via `registerTransports()`.
*/ */
const localAndRemoteTransports$: Behavior< const localAndRemoteTransports$: Behavior<
Epoch<(LivekitTransport | LocalTransport)[]> Epoch<{ transport: LivekitTransport; role: "publisher" | "subscriber" }[]>
> = scope.behavior( > = scope.behavior(
combineLatest([localTransportAsArray$, remoteTransports$]).pipe( combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
// Combine local and remote transports into one transport array // Combine local and remote transports into one transport array, tagged
// and set the forceOldJwtEndpoint property on the local transport // with the role we take on each of them
map(([localTransportAsArray, remoteTransports]) => { map(([localTransportAsArray, remoteTransports]) => {
const dedupedRemote = removeDuplicateTransports(remoteTransports.value); const dedupedRemote = removeDuplicateTransports(remoteTransports.value);
const remoteWithoutLocal = dedupedRemote.filter( const remoteWithoutLocal = dedupedRemote.filter(
(transport) => (transport) =>
!localTransportAsArray.find((l) => !localTransportAsArray.find((l) =>
areLivekitTransportsEqual(l.transport, transport), areLivekitTransportsEqual(l, transport),
), ),
); );
logger.debug( logger.debug(
@@ -167,7 +162,16 @@ export function createConnectionManager$({
localTransportAsArray, localTransportAsArray,
); );
return new Epoch( return new Epoch(
[...localTransportAsArray, ...remoteWithoutLocal], [
...localTransportAsArray.map((transport) => ({
transport,
role: "publisher" as const,
})),
...remoteWithoutLocal.map((transport) => ({
transport,
role: "subscriber" as const,
})),
],
remoteTransports.epoch, remoteTransports.epoch,
); );
}), }),
@@ -182,40 +186,23 @@ export function createConnectionManager$({
generateItemsWithEpoch( generateItemsWithEpoch(
"ConnectionManager connections$", "ConnectionManager connections$",
function* (transports) { function* (transports) {
for (const transport of transports) { for (const { transport, role } of transports) {
if (isLocalTransport(transport)) { yield {
// This is the local transport; only the `LocalTransport` keys: [role, transport.livekit_service_url],
// interface has a `sfuConfig` field. data: undefined,
yield { };
keys: [
transport.transport.livekit_service_url,
transport.sfuConfig,
],
data: undefined,
};
} else {
yield {
keys: [
transport.livekit_service_url,
undefined as SFUConfig | undefined,
],
data: undefined,
};
}
} }
}, },
(scope, _data$, serviceUrl, sfuConfig) => { (scope, _data$, role, serviceUrl) => {
const connection = connectionFactory.createConnection( const connection = connectionFactory.createConnection(
scope, scope,
role,
{ {
type: "livekit", type: "livekit",
livekit_service_url: serviceUrl, livekit_service_url: serviceUrl,
}, },
ownMembershipIdentity, ownMembershipIdentity,
logger, logger,
// TODO: This whole optional SFUConfig parameter is not particularly elegant.
// I would like it if connections always fetched the SFUConfig by themselves.
sfuConfig,
); );
// Start the connection immediately // Start the connection immediately
// Use connection state to track connection progress // Use connection state to track connection progress
@@ -89,6 +89,7 @@ describe("ECConnectionFactory - Audio inputs options", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
ownMemberMock, ownMemberMock,
logger, logger,
@@ -138,6 +139,7 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
ownMemberMock, ownMemberMock,
logger, logger,
@@ -174,6 +176,7 @@ describe("ECConnectionFactory - Camera quality settings", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
ownMemberMock, ownMemberMock,
logger, logger,
@@ -212,6 +215,7 @@ describe("ECConnectionFactory - Camera quality settings", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
ownMemberMock, ownMemberMock,
logger, logger,
@@ -251,6 +255,7 @@ describe("ECConnectionFactory - Camera quality settings", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
ownMemberMock, ownMemberMock,
logger, logger,
-8
View File
@@ -78,7 +78,6 @@ import {
type RemoteScreenShareViewModel, type RemoteScreenShareViewModel,
} from "../state/media/RemoteScreenShareViewModel"; } from "../state/media/RemoteScreenShareViewModel";
import { Connection } from "../state/CallViewModel/remoteMembers/Connection"; import { Connection } from "../state/CallViewModel/remoteMembers/Connection";
import { type SFUConfig } from "../livekit/auth";
export function withFakeTimers(continuation: () => void): void { export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -214,13 +213,6 @@ export const exampleTransport: LivekitTransport = {
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
}; };
export const exampleSfuConfig: SFUConfig = {
jwt: "foo",
livekitAlias: "bar",
livekitIdentity: "baz",
url: "bro",
};
export function mockRtcMembership( export function mockRtcMembership(
user: string | RoomMember, user: string | RoomMember,
deviceId: string, deviceId: string,