Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add error screens for connecting to the JWT service #2952

Open
wants to merge 3 commits into
base: livekit
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}</2>. If you are the server admin, check the network logs and make sure <5>lk-jwt-service</5> is listening at that address.</0>",
"auth_connection_rejected_details": "<0>The application connected to the call authentication service at <2>{{url}}</2>, but it responded with status code {{status}}. If you are the server admin, make sure <8>lk-jwt-service</8> is listening at that address and check the logs for more information.</0>",
"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.",
Expand Down
133 changes: 129 additions & 4 deletions src/RichError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 });
}
}

Expand All @@ -46,3 +57,117 @@ export class OpenElsewhereError extends RichError {
super("App opened in another tab", <OpenElsewhere />);
}
}

interface AuthConnectionFailedProps {
livekitServiceUrl: string;
}

const AuthConnectionFailed: FC<AuthConnectionFailedProps> = ({
livekitServiceUrl,
}) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const onShowDetailsClick = useCallback(() => setShowDetails(true), []);

return (
<ErrorView Icon={OfflineIcon} title={t("error.connection_failed")}>
<p>{t("error.connection_failed_description")}</p>
{showDetails ? (
<Trans
i18nKey="error.auth_connection_failed_details"
url={livekitServiceUrl}
>
<p>
The application could not reach the call authentication service at{" "}
<Link href={livekitServiceUrl} target="_blank">
{{ url: livekitServiceUrl } as unknown as ReactElement}
</Link>
. If you are the server admin, check the network logs and make sure{" "}
<Link
href="https://github.com/element-hq/lk-jwt-service/"
target="_blank"
>
lk-jwt-service
</Link>{" "}
is listening at that address.
</p>
</Trans>
) : (
<Button kind="tertiary" onClick={onShowDetailsClick}>
{t("error.show_details")}
</Button>
)}
</ErrorView>
);
};

export class AuthConnectionFailedError extends RichError {
public constructor(livekitServiceUrl: string, cause: unknown) {
super(
`Failed to connect to ${livekitServiceUrl}`,
<AuthConnectionFailed livekitServiceUrl={livekitServiceUrl} />,
cause,
);
}
}

interface AuthConnectionRejectedProps {
livekitServiceUrl: string;
status: number;
}

const AuthConnectionRejected: FC<AuthConnectionRejectedProps> = ({
livekitServiceUrl,
status,
}) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const onShowDetailsClick = useCallback(() => setShowDetails(true), []);

return (
<ErrorView Icon={OfflineIcon} title={t("error.connection_failed")}>
<p>{t("error.connection_rejected_description")}</p>
{showDetails ? (
<Trans
i18nKey="error.auth_connection_rejected_details"
url={livekitServiceUrl}
status={status}
>
<p>
The application connected to the call authentication service at{" "}
<Link href={livekitServiceUrl} target="_blank">
{{ url: livekitServiceUrl } as unknown as ReactElement}
</Link>
, but it responded with status code{" "}
{{ status } as unknown as ReactElement}. If you are the server
admin, make sure{" "}
<Link
href="https://github.com/element-hq/lk-jwt-service/"
target="_blank"
>
lk-jwt-service
</Link>{" "}
is listening at that address and check the logs for more
information.
</p>
</Trans>
) : (
<Button kind="tertiary" onClick={onShowDetailsClick}>
{t("error.show_details")}
</Button>
)}
</ErrorView>
);
};

export class AuthConnectionRejectedError extends RichError {
public constructor(livekitServiceUrl: string, status: number) {
super(
`Failed to connect to ${livekitServiceUrl} (status ${status})`,
<AuthConnectionRejected
livekitServiceUrl={livekitServiceUrl}
status={status}
/>,
);
}
}
69 changes: 69 additions & 0 deletions src/livekit/openIDSFU.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
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",
});
});
});

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 server returns error", async () => {
await withFetchSpy(async (fetch) => {
fetch.mockResolvedValue({ ok: false, status: 404 } as Response);
await expect(async () =>
getSFUConfigWithOpenID(mockClient, mockFocus),
).rejects.toThrowError(expect.any(AuthConnectionRejectedError));
});
});
60 changes: 28 additions & 32 deletions src/livekit/openIDSFU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,25 +39,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;
}

Expand All @@ -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(
Expand All @@ -92,8 +87,9 @@ async function getLiveKitJWT(
roomName: string,
openIDToken: IOpenIDToken,
): Promise<SFUConfig> {
let res: Response;
try {
const res = await fetch(livekitServiceURL + "/sfu/get", {
res = await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand All @@ -104,11 +100,11 @@ 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 new AuthConnectionRejectedError(livekitServiceURL, res.status);
}
return await res.json();
}
Loading