diff --git a/src/livekit/openIDSFU.test.ts b/src/livekit/openIDSFU.test.ts new file mode 100644 index 00000000..cc77fa7a --- /dev/null +++ b/src/livekit/openIDSFU.test.ts @@ -0,0 +1,124 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + beforeEach, + afterEach, + describe, + expect, + it, + type MockedObject, + vitest, +} from "vitest"; +import fetchMock from "fetch-mock"; +import { getSFUConfigWithOpenID, OpenIDClientParts } from "./openIDSFU"; + +const jwtToken = [ + {}, // header + { + // payload + sub: "@me:example.org:ABCDEF", + video: { + room: "!example_room_id", + }, + }, + {}, // signature +] + .map((d) => global.btoa(JSON.stringify(d))) + .join("."); + +const sfuUrl = "https://sfu.example.org"; + +describe("getSFUConfigWithOpenID", () => { + let matrixClient: MockedObject; + beforeEach(() => { + matrixClient = { + getOpenIdToken: vitest.fn(), + getDeviceId: vitest.fn(), + }; + }); + afterEach(() => { + vitest.clearAllMocks(); + fetchMock.reset(); + }); + it("should handle fetching a token", async () => { + fetchMock.post("https://sfu.example.org/sfu/get", () => { + return { + status: 200, + body: { url: sfuUrl, jwt: jwtToken }, + }; + }); + const config = await getSFUConfigWithOpenID( + matrixClient, + "https://sfu.example.org", + "!example_room_id", + ); + expect(config).toEqual({ + jwt: jwtToken, + url: sfuUrl, + livekitIdentity: "@me:example.org:ABCDEF", + livekitAlias: "!example_room_id", + }); + await fetchMock.flush(); + }); + it("should fail if the SFU errors", async () => { + fetchMock.post("https://sfu.example.org/sfu/get", () => { + return { + status: 500, + body: { error: "Test failure" }, + }; + }); + try { + await getSFUConfigWithOpenID( + matrixClient, + "https://sfu.example.org", + "!example_room_id", + ); + } catch (ex) { + expect(((ex as Error).cause as Error).message).toEqual( + "SFU Config fetch failed with status code 500", + ); + await fetchMock.flush(); + return; + } + expect.fail("Expected test to throw;"); + }); + + it("should retry fetching the openid token", async () => { + let count = 0; + matrixClient.getOpenIdToken.mockImplementation(async () => { + count++; + if (count < 2) { + throw Error("Test failure"); + } + return { + token_type: "Bearer", + access_token: "foobar", + matrix_server_name: "example.org", + expires_in: 30, + }; + }); + fetchMock.post("https://sfu.example.org/sfu/get", () => { + return { + status: 200, + body: { url: sfuUrl, jwt: jwtToken }, + }; + }); + const config = await getSFUConfigWithOpenID( + matrixClient, + "https://sfu.example.org", + "!example_room_id", + ); + expect(config).toEqual({ + jwt: jwtToken, + url: sfuUrl, + livekitIdentity: "@me:example.org:ABCDEF", + livekitAlias: "!example_room_id", + }); + await fetchMock.flush(); + }); +}); diff --git a/src/livekit/openIDSFU.ts b/src/livekit/openIDSFU.ts index 3ae003fb..b3c07397 100644 --- a/src/livekit/openIDSFU.ts +++ b/src/livekit/openIDSFU.ts @@ -11,9 +11,47 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { FailToGetOpenIdToken } from "../utils/errors"; import { doNetworkOperationWithRetry } from "../utils/matrix"; +/** + * Configuration and access tokens provided by the SFU on successful authentication. + */ export interface SFUConfig { url: string; jwt: string; + livekitAlias: string; + livekitIdentity: string; +} + +/** + * Decoded details from the JWT. + */ +interface SFUJWTPayload { + /** + * Expiration time for the JWT. + * Note: This value is in seconds since Unix epoch. + */ + exp: number; + /** + * Name of the instance which authored the JWT + */ + iss: string; + /** + * Time at which the JWT can start to be used. + * Note: This value is in seconds since Unix epoch. + */ + nbf: number; + /** + * Subject. The Livekit alias in this context. + */ + sub: string; + /** + * The set of permissions for the user. + */ + video: { + canPublish: boolean; + canSubscribe: boolean; + room: string; + roomJoin: boolean; + }; } // The bits we need from MatrixClient @@ -57,7 +95,17 @@ export async function getSFUConfigWithOpenID( ); logger.info(`Got JWT from call's active focus URL.`); - return sfuConfig; + // Pull the details from the JWT + const [, payloadStr] = sfuConfig.jwt.split("."); + // TODO: Prefer Uint8Array.fromBase64 when widely available + const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload; + return { + jwt: sfuConfig.jwt, + url: sfuConfig.url, + livekitAlias: payload.video.room, + // NOTE: Currently unused. + livekitIdentity: payload.sub, + }; } async function getLiveKitJWT( @@ -65,7 +113,7 @@ async function getLiveKitJWT( livekitServiceURL: string, roomName: string, openIDToken: IOpenIDToken, -): Promise { +): Promise<{ url: string; jwt: string }> { try { const res = await fetch(livekitServiceURL + "/sfu/get", { method: "POST", @@ -83,6 +131,6 @@ async function getLiveKitJWT( } return await res.json(); } catch (e) { - throw new Error("SFU Config fetch failed with exception " + e); + throw new Error("SFU Config fetch failed with exception", { cause: e }); } }