mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
The OpenID flow moves to src/livekit/auth/openID.ts with the SFU config types in a sibling file, takes an options object, and replaces the MatrixRTC mode with the role we take on the transport plus the session's slot ID instead of a hard-coded m.call#ROOM. This changes behaviour: publishers on a livekit_service_url transport always use the legacy /sfu/get endpoint, so that subscribers can predict their user:device participant identity, and subscribers always use the default /get_token endpoint. The MatrixRTC mode no longer influences which endpoint is used. On the SDK this lands as sdk/src/media-backend/livekit/auth/ with the connection map, the discriminated connection state and the client-server API flow of the following commits of element-hq/element-call#4331, which rewrite the same files: - Model ConnectionState as a discriminated union - Show the whole transport in debug UI and logs (SDK part) - Let the publishing Connection authenticate itself - Reduce ConnectionManager to a ConnectionMap - Use the unstable LiveKit transport type - Identify transports by server name as well as URLs - Get SFU tokens and delegate delayed leaves through the MSC4195 CS API - Fall back to get_token as subscriber on widget API errors - Share a connection between homeservers using the same JWT service - Make the auth choice mode independent again Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org> (cherry picked from commit adb380cd9ed108856e42d992fc03ced8c44f1e56)
408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
/*
|
|
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, describe, expect, it, type Mocked, vi } from "vitest";
|
|
import fetchMock from "fetch-mock";
|
|
import { ConnectionError, MatrixError } from "matrix-js-sdk";
|
|
import { logger } from "matrix-js-sdk/lib/logger";
|
|
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
|
|
|
import { NoMatrix2AuthorizationService } from "../../../errors";
|
|
import {
|
|
aliceDeviceId,
|
|
aliceUserId,
|
|
createJWTToken,
|
|
} from "../../../utils/test-fixtures";
|
|
import type * as csApi from "./csApi";
|
|
import type * as openID from "./openID";
|
|
import { chooseAuthFlow, delegateDelayedLeave, getSFUConfig } from ".";
|
|
|
|
const aliceId = `${aliceUserId}:${aliceDeviceId}`;
|
|
const csApiJWTToken = createJWTToken(aliceId, "via CS API");
|
|
const legacyJWTToken = createJWTToken(aliceId, "via legacy endpoint");
|
|
const defaultJWTToken = createJWTToken(aliceId, "via default endpoint");
|
|
|
|
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 serviceUrl = "https://livekit-jwt.example.org";
|
|
const serviceUrlTransport: UnstableLivekitTransport = {
|
|
type: "livekit",
|
|
livekit_service_url: serviceUrl,
|
|
};
|
|
|
|
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
|
|
...urlTransport,
|
|
...serviceUrlTransport,
|
|
};
|
|
|
|
/** The JWT service, answering each endpoint with a token that names it. */
|
|
function mockJWTService(): void {
|
|
fetchMock.post(`${serviceUrl}/sfu/get`, {
|
|
status: 200,
|
|
body: { url: "wss://sfu", jwt: legacyJWTToken },
|
|
});
|
|
fetchMock.post(`${serviceUrl}/get_token`, {
|
|
status: 200,
|
|
body: { url: "wss://sfu", jwt: defaultJWTToken },
|
|
});
|
|
}
|
|
|
|
/** Whether the JWT service was asked at all, and which endpoint. */
|
|
function jwtServiceCalls(): string[] {
|
|
return fetchMock.calls().map(([url]) => url.replace(serviceUrl, ""));
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
fetchMock.reset();
|
|
});
|
|
|
|
describe("getSFUConfig", () => {
|
|
const defaultParams = {
|
|
client,
|
|
membership,
|
|
serverName: "example.org",
|
|
role: "subscriber" as const,
|
|
roomId,
|
|
slotId,
|
|
logger,
|
|
};
|
|
|
|
it("uses CS API with url transport", async () => {
|
|
mockJWTService();
|
|
expect(
|
|
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
jwt: csApiJWTToken,
|
|
livekitAlias: "via CS API",
|
|
}),
|
|
);
|
|
expect(client._unstable_getLivekitToken).toHaveBeenCalledWith({
|
|
url: urlTransport.url,
|
|
server_name: "example.org",
|
|
room_id: roomId,
|
|
slot_id: slotId,
|
|
member_id: "123",
|
|
});
|
|
expect(jwtServiceCalls()).toEqual([]);
|
|
});
|
|
|
|
it("does not fall back to OpenID with url transport", async () => {
|
|
mockJWTService();
|
|
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
|
await expect(
|
|
getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
|
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
|
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
|
expect(jwtServiceCalls()).toEqual([]);
|
|
});
|
|
|
|
it("uses OpenID with livekit_service_url transport", async () => {
|
|
mockJWTService();
|
|
expect(
|
|
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
jwt: defaultJWTToken,
|
|
livekitAlias: "via default endpoint",
|
|
}),
|
|
);
|
|
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
|
expect(jwtServiceCalls()).toEqual(["/get_token"]);
|
|
});
|
|
|
|
it("uses CS API when subscribing with url + livekit_service_url transport", async () => {
|
|
mockJWTService();
|
|
expect(
|
|
await getSFUConfig({
|
|
...defaultParams,
|
|
transport: urlAndServiceUrlTransport,
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
jwt: csApiJWTToken,
|
|
livekitAlias: "via CS API",
|
|
}),
|
|
);
|
|
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
|
expect(jwtServiceCalls()).toEqual([]);
|
|
});
|
|
|
|
it("falls back to OpenID with url + livekit_service_url transport", async () => {
|
|
mockJWTService();
|
|
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
|
expect(
|
|
await getSFUConfig({
|
|
...defaultParams,
|
|
transport: urlAndServiceUrlTransport,
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
jwt: defaultJWTToken,
|
|
livekitAlias: "via default endpoint",
|
|
}),
|
|
);
|
|
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
|
expect(jwtServiceCalls()).toEqual(["/get_token"]);
|
|
});
|
|
|
|
it("falls back to OpenID when the widget host refuses the CS API", async () => {
|
|
mockJWTService();
|
|
// 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: defaultJWTToken }));
|
|
expect(jwtServiceCalls()).toEqual(["/get_token"]);
|
|
});
|
|
|
|
it("surfaces the widget host's refusal with url transport", async () => {
|
|
mockJWTService();
|
|
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(jwtServiceCalls()).toEqual([]);
|
|
});
|
|
|
|
it("uses CS API when publishing with url transport", async () => {
|
|
mockJWTService();
|
|
expect(
|
|
await getSFUConfig({
|
|
...defaultParams,
|
|
transport: urlTransport,
|
|
role: "publisher",
|
|
}),
|
|
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
|
|
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
|
expect(jwtServiceCalls()).toEqual([]);
|
|
});
|
|
|
|
it("does not fall back to OpenID when publishing", async () => {
|
|
mockJWTService();
|
|
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
|
await expect(
|
|
getSFUConfig({
|
|
...defaultParams,
|
|
transport: urlTransport,
|
|
role: "publisher",
|
|
}),
|
|
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
|
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
|
expect(jwtServiceCalls()).toEqual([]);
|
|
});
|
|
|
|
it("uses the legacy OpenID endpoint when publishing with url + livekit_service_url transport", async () => {
|
|
mockJWTService();
|
|
// Subscribers predating MSC4195 expect a legacy participant identity from
|
|
// anyone advertising a membership, which only the JWT service assigns
|
|
expect(
|
|
await getSFUConfig({
|
|
...defaultParams,
|
|
transport: urlAndServiceUrlTransport,
|
|
role: "publisher",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
jwt: legacyJWTToken,
|
|
livekitAlias: "via legacy endpoint",
|
|
}),
|
|
);
|
|
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
|
expect(jwtServiceCalls()).toEqual(["/sfu/get"]);
|
|
});
|
|
|
|
it("does not fall back to OpenID on connection error", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
mockJWTService();
|
|
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(jwtServiceCalls()).toEqual([]);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("delegateDelayedLeave", () => {
|
|
const defaultParams = {
|
|
client,
|
|
membership,
|
|
roomId,
|
|
slotId,
|
|
delayId: "delay123",
|
|
delayTimeoutMs: 3_600_000,
|
|
logger,
|
|
};
|
|
|
|
/** The delay fields the JWT service was handed, if any. */
|
|
function delegationViaJWTService(): object | undefined {
|
|
const call = fetchMock.calls().find(([url]) => url.endsWith("/sfu/get"));
|
|
if (call === undefined) return undefined;
|
|
const { delay_id, delay_timeout, delay_cs_api_url } = JSON.parse(
|
|
call[1]?.body as string,
|
|
);
|
|
return { delay_id, delay_timeout, delay_cs_api_url };
|
|
}
|
|
|
|
it("uses CS API with url transport", async () => {
|
|
mockJWTService();
|
|
await delegateDelayedLeave({ ...defaultParams, transport: urlTransport });
|
|
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalledWith({
|
|
url: urlTransport.url,
|
|
room_id: roomId,
|
|
slot_id: slotId,
|
|
member_id: "123",
|
|
delay_id: "delay123",
|
|
});
|
|
expect(jwtServiceCalls()).toEqual([]);
|
|
});
|
|
|
|
it("does not fall back to OpenID with url transport", async () => {
|
|
mockJWTService();
|
|
client._unstable_delegateDelayedLeave.mockRejectedValueOnce(
|
|
unsupportedError,
|
|
);
|
|
await expect(
|
|
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
|
|
).rejects.toThrow();
|
|
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
|
|
expect(jwtServiceCalls()).toEqual([]);
|
|
});
|
|
|
|
it("uses OpenID with livekit_service_url transport", async () => {
|
|
mockJWTService();
|
|
await delegateDelayedLeave({
|
|
...defaultParams,
|
|
transport: serviceUrlTransport,
|
|
});
|
|
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
|
|
expect(delegationViaJWTService()).toEqual({
|
|
delay_id: "delay123",
|
|
delay_timeout: 3_600_000,
|
|
delay_cs_api_url: client.baseUrl,
|
|
});
|
|
});
|
|
|
|
it("uses OpenID with url + livekit_service_url transport", async () => {
|
|
mockJWTService();
|
|
// 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(delegationViaJWTService()).toEqual({
|
|
delay_id: "delay123",
|
|
delay_timeout: 3_600_000,
|
|
delay_cs_api_url: client.baseUrl,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("chooseAuthFlow", () => {
|
|
it.each([
|
|
[
|
|
"url",
|
|
"publisher",
|
|
urlTransport,
|
|
{ kind: "csApi", url: urlTransport.url },
|
|
],
|
|
[
|
|
"url",
|
|
"subscriber",
|
|
urlTransport,
|
|
{ kind: "csApi", url: urlTransport.url },
|
|
],
|
|
[
|
|
"livekit_service_url",
|
|
"publisher",
|
|
serviceUrlTransport,
|
|
{ kind: "openID", serviceUrl },
|
|
],
|
|
[
|
|
"livekit_service_url",
|
|
"subscriber",
|
|
serviceUrlTransport,
|
|
{ kind: "openID", serviceUrl },
|
|
],
|
|
[
|
|
"url + livekit_service_url",
|
|
"publisher",
|
|
urlAndServiceUrlTransport,
|
|
{ kind: "openID", serviceUrl },
|
|
],
|
|
[
|
|
"url + livekit_service_url",
|
|
"subscriber",
|
|
urlAndServiceUrlTransport,
|
|
{ kind: "csApi", url: urlTransport.url },
|
|
],
|
|
] as const)(
|
|
"chooses the flow for a %s transport as %s",
|
|
(_shape, role, transport, flow) => {
|
|
expect(chooseAuthFlow(transport, role)).toEqual(flow);
|
|
},
|
|
);
|
|
});
|