Compare commits

...

7 Commits

Author SHA1 Message Date
Hugh Nimmo-Smith
3899901c34 Actually validate lk-jwt-service response 2025-01-22 15:47:47 +00:00
Hugh Nimmo-Smith
4ca99f0e59 For discussion 2025-01-20 17:25:46 +00:00
Robin
d901e21804 Assert that the request to the JWT service looks right 2025-01-19 21:12:26 -05:00
Robin
9dd540d91e Count 404 as a connection failure 2025-01-19 21:11:58 -05:00
Robin
9b532e6720 Test the JWT service errors 2025-01-17 08:56:00 -05:00
Robin
8bf617395b Simplify error wording further 2025-01-17 08:56:00 -05:00
Robin
5240d5eafb Add error screens for connecting to the JWT service 2025-01-17 08:56:00 -05:00
4 changed files with 392 additions and 37 deletions

View File

@@ -72,16 +72,26 @@
},
"disconnected_banner": "Connectivity to the server has been lost.",
"error": {
"configuration_error": "Configuration error",
"configuration_error_description": "There is a configuration issues with the system. Please contact your administrator.",
"server_error": "Server error",
"server_error_description": "There is a server issue with the system. Please try again and contact your administrator if the problem persists.",
"network_error": "Network error",
"network_error_description": "There is a network issue with the system. Please check your network connection and try again. Alternatively try a different network if available.",
"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</1>.</0>",
"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.",

View File

@@ -5,9 +5,20 @@ 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 {
ErrorIcon,
OfflineIcon,
PopOutIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";
import { ErrorView } from "./ErrorView";
@@ -22,8 +33,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 +58,212 @@ export class OpenElsewhereError extends RichError {
super("App opened in another tab", <OpenElsewhere />);
}
}
interface ConfigurationErrorViewProps {
children?: ReactNode;
}
const ConfigurationErrorView: FC<ConfigurationErrorViewProps> = ({
children,
}) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const onShowDetailsClick = useCallback(() => setShowDetails(true), []);
return (
<ErrorView Icon={ErrorIcon} title={t("error.configuration_error")}>
<p>{t("error.configuration_error_description")}</p>
{showDetails ? (
children
) : (
<Button kind="tertiary" onClick={onShowDetailsClick}>
{t("error.show_details")}
</Button>
)}
</ErrorView>
);
};
interface NetworkErrorViewProps {
children?: ReactNode;
}
const NetworkErrorView: FC<NetworkErrorViewProps> = ({ children }) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const onShowDetailsClick = useCallback(() => setShowDetails(true), []);
return (
<ErrorView Icon={OfflineIcon} title={t("error.network_error")}>
<p>{t("error.network_error_description")}</p>
{showDetails ? (
children
) : (
<Button kind="tertiary" onClick={onShowDetailsClick}>
{t("error.show_details")}
</Button>
)}
</ErrorView>
);
};
interface ServerErrorViewProps {
children?: ReactNode;
}
const ServerErrorView: FC<ServerErrorViewProps> = ({ children }) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const onShowDetailsClick = useCallback(() => setShowDetails(true), []);
return (
<ErrorView Icon={ErrorIcon} title={t("error.server_error")}>
<p>{t("error.server_error_description")}</p>
{showDetails ? (
children
) : (
<Button kind="tertiary" onClick={onShowDetailsClick}>
{t("error.show_details")}
</Button>
)}
</ErrorView>
);
};
export class ConfigurationError extends RichError {
public constructor(message: string, richMessage: ReactNode, cause?: unknown) {
super(
message,
<ConfigurationErrorView>{richMessage}</ConfigurationErrorView>,
cause,
);
}
}
export class NetworkError extends RichError {
public constructor(message: string, richMessage: ReactNode, cause?: unknown) {
super(message, <NetworkErrorView>{richMessage}</NetworkErrorView>, cause);
}
}
export class ServerError extends RichError {
public constructor(message: string, richMessage: ReactNode, cause?: unknown) {
super(message, <ServerErrorView>{richMessage}</ServerErrorView>, cause);
}
}
export class URLBuildingConfigurationError extends ConfigurationError {
public constructor(baseUrl: string, cause?: unknown) {
let message: string;
if (cause instanceof Error) {
message = cause.message;
} else {
message = "Unknown error";
}
super(
`Unable to build URL based on: ${baseUrl}`,
<Trans
i18nKey="error.invalid_url_details"
baseUrl={baseUrl}
message={message}
>
<p>
The URL derived from{" "}
<code>{{ baseUrl } as unknown as ReactElement}</code> is not valid:{" "}
<pre>{{ message } as unknown as ReactElement}</pre>
</p>
</Trans>,
cause,
);
}
}
export class ResourceNotFoundConfigurationError extends ConfigurationError {
public constructor(url: URL) {
super(
`The server returned a 404 response for: ${url.href}`,
<Trans i18nKey="error.resource_not_found_details" url={url.href}>
<p>
The request to{" "}
<code>{{ url: url.href } as unknown as ReactElement}</code> returned a{" "}
<code>404</code> response.
</p>
</Trans>,
);
}
}
export class UnexpectedResponseCodeError extends ServerError {
public constructor(url: URL, status: number, response: string) {
super(
`Received unexpected response code from ${url.href}: ${status}`,
<Trans
i18nKey="error.unexpected_response_code_details"
url={url.href}
status={status}
response={response}
>
<p>
The application received an unexpected response from{" "}
<code>{{ url } as unknown as ReactElement}</code>. It received status
code <code>{{ status } as unknown as ReactElement}</code>:{" "}
<pre>{{ response } as unknown as ReactElement}</pre>.
</p>
</Trans>,
);
}
}
export class FetchError extends ServerError {
public constructor(url: URL, cause: unknown) {
let message: string;
if (cause instanceof Error) {
message = cause.message;
} else {
message = "Unknown error";
}
super(
`Failed to connect to ${url.href}: ${message}`,
<Trans
i18nKey="error.fetch_error_details"
url={url.href}
message={message}
>
<p>
The application received an unexpected response from{" "}
<code>{{ url: url.href } as unknown as ReactElement}</code>. It
received status code{" "}
<code>{{ message } as unknown as ReactElement}</code>.
</p>
</Trans>,
);
}
}
export class InvalidServerResponseError extends ServerError {
public constructor(url: URL, cause: unknown) {
let message: string;
if (cause instanceof Error) {
message = cause.message;
} else {
message = "Unknown error";
}
super(
`Invalid response received from ${url.href}: ${message}`,
<Trans
i18nKey="error.invalid_server_response_error_details"
url={url.href}
message={message}
>
<p>
The server at{" "}
<code>{{ url: url.href } as unknown as ReactElement}</code> returned
an invalid response:{" "}
<pre>{{ message } as unknown as ReactElement}</pre>
</p>
</Trans>,
);
}
}

View File

@@ -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<typeof fetch>) => Promise<void>,
): Promise<void> {
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));
});
});

View File

@@ -12,6 +12,13 @@ import { useEffect, useState } from "react";
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { useActiveLivekitFocus } from "../room/useActiveFocus";
import {
FetchError,
InvalidServerResponseError,
ResourceNotFoundConfigurationError,
UnexpectedResponseCodeError,
URLBuildingConfigurationError,
} from "../RichError";
export interface SFUConfig {
url: string;
@@ -35,25 +42,24 @@ export function useOpenIDSFU(
client: OpenIDClientParts,
rtcSession: MatrixRTCSession,
): SFUConfig | undefined {
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
const [sfuConfig, setSFUConfig] = useState<SFUConfig | Error | undefined>(
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 +70,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 +90,18 @@ async function getLiveKitJWT(
roomName: string,
openIDToken: IOpenIDToken,
): Promise<SFUConfig> {
let url: URL;
try {
const res = await fetch(livekitServiceURL + "/sfu/get", {
// TODO: check that relative URLs are handled as expected by this
url = new URL("sfu/get", livekitServiceURL);
} catch (e) {
throw new URLBuildingConfigurationError(livekitServiceURL, e);
}
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -104,11 +112,34 @@ 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 FetchError(url, e);
}
if (!res.ok) {
throw res.status === 404
? new ResourceNotFoundConfigurationError(url)
: new UnexpectedResponseCodeError(url, res.status, await res.text());
}
try {
const json = await res.json();
if (typeof json.jwt !== "string") {
// We don't need to check that the JWT is valid, because we pass it through to
// the SFU opaquely.
throw new Error("Invalid jwt field in server response: not string");
}
if (typeof json.url !== "string") {
throw new Error("Invalid url field in server response: not string");
}
if (!json.url.startsWith("wss://")) {
throw new Error("Invalid url field in server response: not a wss:// URL");
}
return {
jwt: json.jwt,
url: json.url,
};
} catch (e) {
throw new InvalidServerResponseError(url, e);
}
}