diff --git a/locales/en/app.json b/locales/en/app.json index b72af0d54..7552e4b9d 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -72,16 +72,22 @@ }, "disconnected_banner": "Connectivity to the server has been lost.", "error": { + "auth_connection_failed_details": "<0>The application could not reach the call authentication service at <2>{{url}}. If you are the server admin, check the network logs and make sure <5>lk-jwt-service is listening at that address.", + "auth_connection_rejected_details": "<0>The application connected to the call authentication service at <2>{{url}}, but it responded with status code {{status}} ({{response}}). If you are the server admin, make sure <10>lk-jwt-service is listening at that address and check the logs for more information.", "call_not_found": "Call not found", "call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one.", + "connection_failed": "Connection failed", + "connection_failed_description": "Could not connect to the call. Check your network connection or try again later.", "connection_lost": "Connection lost", "connection_lost_description": "You were disconnected from the call.", + "connection_rejected_description": "Could not connect to the call. Try again later or contact your server admin if this persists.", "e2ee_unsupported": "Incompatible browser", "e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.", "generic": "Something went wrong", "generic_description": "Submitting debug logs will help us track down the problem.", "open_elsewhere": "Opened in another tab", - "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page." + "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.", + "show_details": "Show details" }, "group_call_loader": { "banned_body": "You have been banned from the room.", diff --git a/src/RichError.tsx b/src/RichError.tsx index effc76107..e5b01515c 100644 --- a/src/RichError.tsx +++ b/src/RichError.tsx @@ -5,9 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { type FC, type ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { + type ReactElement, + useCallback, + useState, + type FC, + type ReactNode, +} from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { + OfflineIcon, + PopOutIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Button, Link } from "@vector-im/compound-web"; import { ErrorView } from "./ErrorView"; @@ -22,8 +32,9 @@ export class RichError extends Error { * The pretty, more helpful message to be shown on the error screen. */ public readonly richMessage: ReactNode, + cause?: unknown, ) { - super(message); + super(message, { cause }); } } @@ -46,3 +57,126 @@ export class OpenElsewhereError extends RichError { super("App opened in another tab", ); } } + +interface AuthConnectionFailedProps { + livekitServiceUrl: string; +} + +const AuthConnectionFailed: FC = ({ + livekitServiceUrl, +}) => { + const { t } = useTranslation(); + const [showDetails, setShowDetails] = useState(false); + const onShowDetailsClick = useCallback(() => setShowDetails(true), []); + + return ( + +

{t("error.connection_failed_description")}

+ {showDetails ? ( + +

+ The application could not reach the call authentication service at{" "} + + {{ url: livekitServiceUrl } as unknown as ReactElement} + + . If you are the server admin, check the network logs and make sure{" "} + + lk-jwt-service + {" "} + is listening at that address. +

+
+ ) : ( + + )} +
+ ); +}; + +export class AuthConnectionFailedError extends RichError { + public constructor(livekitServiceUrl: string, cause?: unknown) { + super( + `Failed to connect to ${livekitServiceUrl}`, + , + cause, + ); + } +} + +interface AuthConnectionRejectedProps { + livekitServiceUrl: string; + status: number; + response: string; +} + +const AuthConnectionRejected: FC = ({ + livekitServiceUrl, + status, + response, +}) => { + const { t } = useTranslation(); + const [showDetails, setShowDetails] = useState(false); + const onShowDetailsClick = useCallback(() => setShowDetails(true), []); + + return ( + +

{t("error.connection_rejected_description")}

+ {showDetails ? ( + +

+ The application connected to the call authentication service at{" "} + + {{ url: livekitServiceUrl } as unknown as ReactElement} + + , but it responded with status code{" "} + {{ status } as unknown as ReactElement} ( + {{ response } as unknown as ReactElement}). If you are the server + admin, make sure{" "} + + lk-jwt-service + {" "} + is listening at that address and check the logs for more + information. +

+
+ ) : ( + + )} +
+ ); +}; + +export class AuthConnectionRejectedError extends RichError { + public constructor( + livekitServiceUrl: string, + status: number, + response: string, + ) { + super( + `Failed to connect to ${livekitServiceUrl} (status ${status})`, + , + ); + } +} diff --git a/src/livekit/openIDSFU.test.ts b/src/livekit/openIDSFU.test.ts new file mode 100644 index 000000000..17806e45a --- /dev/null +++ b/src/livekit/openIDSFU.test.ts @@ -0,0 +1,93 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { expect, type MockInstance, test, vi } from "vitest"; +import { type IOpenIDToken } from "matrix-js-sdk/src/client"; +import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc"; + +import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU"; +import { + AuthConnectionFailedError, + AuthConnectionRejectedError, +} from "../RichError"; + +async function withFetchSpy( + continuation: (fetchSpy: MockInstance) => Promise, +): Promise { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + await continuation(fetchSpy); + } finally { + fetchSpy.mockRestore(); + } +} + +const mockClient: OpenIDClientParts = { + getOpenIdToken: async () => Promise.resolve({} as IOpenIDToken), + getDeviceId: () => "Device ID", +}; +const mockFocus: LivekitFocus = { + type: "livekit", + livekit_alias: "LiveKit alias", + livekit_service_url: "LiveKit service URL", +}; + +test("getSFUConfigWithOpenID gets the JWT token", async () => { + await withFetchSpy(async (fetch) => { + fetch.mockResolvedValue({ + ok: true, + json: async () => + Promise.resolve({ jwt: "JWT token", url: "LiveKit URL" }), + } as Response); + expect(await getSFUConfigWithOpenID(mockClient, mockFocus)).toEqual({ + jwt: "JWT token", + url: "LiveKit URL", + }); + expect(fetch).toHaveBeenCalledWith("LiveKit service URL/sfu/get", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + room: "LiveKit alias", + openid_token: {}, + device_id: "Device ID", + }), + }); + }); +}); + +test("getSFUConfigWithOpenID throws if connection fails", async () => { + await withFetchSpy(async (fetch) => { + fetch.mockRejectedValue(new Error("Connection failed")); + await expect(async () => + getSFUConfigWithOpenID(mockClient, mockFocus), + ).rejects.toThrowError(expect.any(AuthConnectionFailedError)); + }); +}); + +test("getSFUConfigWithOpenID throws if endpoint is not found", async () => { + await withFetchSpy(async (fetch) => { + fetch.mockResolvedValue({ ok: false, status: 404 } as Response); + await expect(async () => + getSFUConfigWithOpenID(mockClient, mockFocus), + ).rejects.toThrowError(expect.any(AuthConnectionFailedError)); + }); +}); + +test("getSFUConfigWithOpenID throws if endpoint returns error", async () => { + await withFetchSpy(async (fetch) => { + fetch.mockResolvedValue({ + ok: false, + status: 503, + text: async () => Promise.resolve("Internal server error"), + } as Response); + await expect(async () => + getSFUConfigWithOpenID(mockClient, mockFocus), + ).rejects.toThrowError(expect.any(AuthConnectionRejectedError)); + }); +}); diff --git a/src/livekit/openIDSFU.ts b/src/livekit/openIDSFU.ts index ab696d4ea..da6e5777f 100644 --- a/src/livekit/openIDSFU.ts +++ b/src/livekit/openIDSFU.ts @@ -12,6 +12,10 @@ import { useEffect, useState } from "react"; import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus"; import { useActiveLivekitFocus } from "../room/useActiveFocus"; +import { + AuthConnectionFailedError, + AuthConnectionRejectedError, +} from "../RichError"; export interface SFUConfig { url: string; @@ -35,25 +39,24 @@ export function useOpenIDSFU( client: OpenIDClientParts, rtcSession: MatrixRTCSession, ): SFUConfig | undefined { - const [sfuConfig, setSFUConfig] = useState(undefined); + const [sfuConfig, setSFUConfig] = useState( + undefined, + ); const activeFocus = useActiveLivekitFocus(rtcSession); useEffect(() => { if (activeFocus) { getSFUConfigWithOpenID(client, activeFocus).then( - (sfuConfig) => { - setSFUConfig(sfuConfig); - }, - (e) => { - logger.error("Failed to get SFU config", e); - }, + (sfuConfig) => setSFUConfig(sfuConfig), + (e) => setSFUConfig(e), ); } else { setSFUConfig(undefined); } }, [client, activeFocus]); + if (sfuConfig instanceof Error) throw sfuConfig; return sfuConfig; } @@ -64,26 +67,18 @@ export async function getSFUConfigWithOpenID( const openIdToken = await client.getOpenIdToken(); logger.debug("Got openID token", openIdToken); - try { - logger.info( - `Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`, - ); - const sfuConfig = await getLiveKitJWT( - client, - activeFocus.livekit_service_url, - activeFocus.livekit_alias, - openIdToken, - ); - logger.info(`Got JWT from call's active focus URL.`); + logger.info( + `Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`, + ); + const sfuConfig = await getLiveKitJWT( + client, + activeFocus.livekit_service_url, + activeFocus.livekit_alias, + openIdToken, + ); + logger.info(`Got JWT from call's active focus URL.`); - return sfuConfig; - } catch (e) { - logger.warn( - `Failed to get JWT from RTC session's active focus URL of ${activeFocus.livekit_service_url}.`, - e, - ); - return undefined; - } + return sfuConfig; } async function getLiveKitJWT( @@ -92,8 +87,9 @@ async function getLiveKitJWT( roomName: string, openIDToken: IOpenIDToken, ): Promise { + let res: Response; try { - const res = await fetch(livekitServiceURL + "/sfu/get", { + res = await fetch(livekitServiceURL + "/sfu/get", { method: "POST", headers: { "Content-Type": "application/json", @@ -104,11 +100,17 @@ async function getLiveKitJWT( device_id: client.getDeviceId(), }), }); - if (!res.ok) { - throw new Error("SFU Config fetch failed with status code " + res.status); - } - return await res.json(); } catch (e) { - throw new Error("SFU Config fetch failed with exception " + e); + throw new AuthConnectionFailedError(livekitServiceURL, e); + } + if (!res.ok) { + throw res.status === 404 + ? new AuthConnectionFailedError(livekitServiceURL) + : new AuthConnectionRejectedError( + livekitServiceURL, + res.status, + await res.text(), + ); } + return await res.json(); }