From 8561800676a7c71db362eefb41b17bf36fc9e110 Mon Sep 17 00:00:00 2001 From: Lauryn Menard Date: Fri, 10 Jan 2025 19:59:53 +0100 Subject: [PATCH] video-calls: Add Zoom Serverto Server OAuth integration. Adds a second Zoom integration that uses the Zoom Server to Server OAuth app process. Only one of the two Zoom integrations can be configured on a Zulip server. Adds a cache for the access token from the Zoom server so that it can be used by the server to create meetings for the approximate duration of the access token In the web-app compose box, if the user's delivery email does not match a user on the configured Zoom account for the server to server integration, then a compose box error banner will be shown when the error response is received after clicking/selecting the video or audio call button. Also updates the production documentation for the both types of Zoom integration apps (Server to Server and General). The General app process for Zoom now requires unlisted apps to go through their review process, which we now have documented. Fixes #33117. --- api_docs/changelog.md | 6 + docs/production/video-calls.md | 93 +++++++++-- version.py | 2 +- web/src/compose_banner.ts | 14 ++ web/src/compose_call.ts | 4 +- web/src/compose_call_ui.ts | 26 +-- web/src/state_data.ts | 1 + .../unknown_zoom_user_error.hbs | 7 + zerver/lib/cache.py | 8 + zerver/lib/exceptions.py | 1 + zerver/models/realms.py | 16 +- zerver/openapi/zulip.yaml | 28 +++- zerver/tests/test_create_video_call.py | 148 +++++++++++++++++- zerver/tests/test_realm.py | 32 +++- zerver/views/video_calls.py | 83 ++++++++++ zproject/default_settings.py | 1 + zproject/prod_settings_template.py | 3 +- zproject/test_extra_settings.py | 1 + 18 files changed, 435 insertions(+), 39 deletions(-) create mode 100644 web/templates/compose_banner/unknown_zoom_user_error.hbs diff --git a/api_docs/changelog.md b/api_docs/changelog.md index f4580a10eb..6c0c9fd68c 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 353** + +* [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events), + `PATCH /realm`: Zoom Server to Server OAuth integration added as an option + for the realm setting `video_chat_provider`. + **Feature level 352** * `PATCH /realm`, [`POST /register`](/api/register-queue), diff --git a/docs/production/video-calls.md b/docs/production/video-calls.md index 22081924cc..eb5a14ef6d 100644 --- a/docs/production/video-calls.md +++ b/docs/production/video-calls.md @@ -32,35 +32,96 @@ No server configuration changes are required. ## Zoom -To use the [Zoom](https://zoom.us) integration on a self-hosted -installation, you'll need to register a custom Zoom app as follows: +To use a [Zoom](https://zoom.us) integration on a self-hosted +installation, you'll need to register a custom Zoom application for +your Zulip server. + +Zulip supports two types of custom Zoom apps: + +- [Server to Server OAuth app][], which is easiest to setup, but + requires users to be part of the Zoom organization that created the + application in order to create calls. +- [General OAuth app][], which is used by Zulip Cloud and other + settings where the server-to-server integration's limitations are + problematic. + +### Server to Server OAuth app + +This Zoom application type, introduced in Zulip 10.0, is easiest to +set up, and is ideal for most installations that self-host Zulip. To +[create Zoom meeting links in Zulip +messages](https://zulip.com/help/start-a-call#start-a-call) using this +integration, users will will need to be members of your Zoom +organization and use the same email address in Zulip that they have +registered with Zoom. + +You can set up this integration as follows: 1. Select [**Build App**](https://marketplace.zoom.us/develop/create) - at the Zoom Marketplace. + at the Zoom Marketplace. Create a **Server to Server OAuth App**. -1. Create an app with the **OAuth** type. +1. Choose an app name such as "ExampleCorp Zulip". + +1. In the **Information** tab: + + - Add a short description and company name. + - Add a name and email for the developer contact information. + +1. In the **Scopes** tab, add the `meeting:write:meeting:admin` and + `meeting:write:meeting:master` scopes. + +1. In the **Activation** tab, activate your app. You can now + [configure your Zulip server](#configure-your-zulip-server) + to use the app. + +### General OAuth app + +This Zoom application type is more flexible than the server-to-server +integration. However, current Zoom policy requires all General OAuth +apps to go through the full Zoom Marketplace review process, even for +[unlisted +apps](https://developers.zoom.us/docs/platform/key-concepts/#private-vs-beta-vs-published-vs-unlisted-apps) +that will only be used by a single customer. As a result, you have to +do quite a bit of publishing overhead work in order to create this +type of Zoom application for your Zulip server. + +1. Select [**Build App**](https://marketplace.zoom.us/develop/create) + at the Zoom Marketplace. Create a **General App**. + +1. In the **Basic Information** tab: - Choose an app name such as "ExampleCorp Zulip". - Select **User-managed app**. - - Disable the option to publish the app on the Marketplace. - - Click **Create**. - -1. Inside the Zoom app management page: - - - On the **App Credentials** tab, set both the **Redirect URL for - OAuth** and the **Whitelist URL** to - `https://zulip.example.com/calls/zoom/complete` (replacing + - In the **OAuth Information** section, set the **OAuth Redirect URL** + to `https://zulip.example.com/calls/zoom/complete` (replacing `zulip.example.com` by your main Zulip hostname). - - On the **Scopes** tab, add the `meeting:write` scope. -You can then configure your Zulip server to use that Zoom app as -follows: +1. In the **Scopes** tab, add the `meeting:write:meeting` scope. + +1. Switch to the **Production** tab and complete the information needed + for the [Zoom App Marketplace Review + Process](https://developers.zoom.us/docs/distribute/app-review-process/) + in the **App Listing** and **Technical Design** tabs. + +1. [Submit your app for + review](https://developers.zoom.us/docs/build-flow/submitting-apps-for-review/). + Select the **Publish the app by myself** option on the **App Submission** + page so that the app will be + [unlisted](https://developers.zoom.us/docs/build-flow/publishing-your-apps/#unlisted-apps). + +1. Once your app has been approved by the Zoom app review team, then + you can proceed to [configure your Zulip server](#configure-your-zulip-server) + to use the app. + +### Configure your Zulip server 1. In `/etc/zulip/zulip-secrets.conf`, set `video_zoom_client_secret` to be your app's "Client Secret". 1. In `/etc/zulip/settings.py`, set `VIDEO_ZOOM_CLIENT_ID` to your - app's "Client ID". + app's "Client ID". If your using a Zoom + [Server to Server OAuth app](#server-to-server-oauth-app), + set `VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID` to be your app's "Account ID". 1. Restart the Zulip server with `/home/zulip/deployments/current/scripts/restart-server`. diff --git a/version.py b/version.py index 2f7b08f338..35e78de322 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 352 # Last bumped for can_mention_many_users_group. +API_FEATURE_LEVEL = 353 # Last bumped for Zoom server to server video chat option. # Bump the minor PROVISION_VERSION to indicate that folks should provision # only when going from an old version of the code to a newer version. Bump diff --git a/web/src/compose_banner.ts b/web/src/compose_banner.ts index 5208b25a2b..2e75565106 100644 --- a/web/src/compose_banner.ts +++ b/web/src/compose_banner.ts @@ -3,6 +3,7 @@ import $ from "jquery"; import render_cannot_send_direct_message_error from "../templates/compose_banner/cannot_send_direct_message_error.hbs"; import render_compose_banner from "../templates/compose_banner/compose_banner.hbs"; import render_stream_does_not_exist_error from "../templates/compose_banner/stream_does_not_exist_error.hbs"; +import render_unknown_zoom_user_error from "../templates/compose_banner/unknown_zoom_user_error.hbs"; import {$t} from "./i18n.ts"; import * as scroll_util from "./scroll_util.ts"; @@ -61,6 +62,7 @@ export const CLASSNAMES = { zephyr_not_running: "zephyr_not_running", generic_compose_error: "generic_compose_error", user_not_subscribed: "user_not_subscribed", + unknown_zoom_user: "unknown_zoom_user", }; export function get_compose_banner_container($textarea: JQuery): JQuery { @@ -257,6 +259,18 @@ export function show_stream_not_subscribed_error(sub: StreamSubscription): void append_compose_banner_to_banner_list($(new_row_html), $banner_container); } +export function show_unknown_zoom_user_error(email: string): void { + // Remove any existing banners with this warning. + $(`#compose_banners .${CSS.escape(CLASSNAMES.unknown_zoom_user)}`).remove(); + + const new_row_html = render_unknown_zoom_user_error({ + banner_type: ERROR, + email, + classname: CLASSNAMES.unknown_zoom_user, + }); + append_compose_banner_to_banner_list($(new_row_html), $("#compose_banners")); +} + export function has_error(): boolean { return $("#compose_banners .error:visible").length > 0; } diff --git a/web/src/compose_call.ts b/web/src/compose_call.ts index 1a968a6a2b..dcdf6f93ff 100644 --- a/web/src/compose_call.ts +++ b/web/src/compose_call.ts @@ -41,7 +41,9 @@ export function compute_show_audio_chat_button(): boolean { (available_providers.zoom && realm.realm_video_chat_provider === available_providers.zoom.id) || (available_providers.big_blue_button && - realm.realm_video_chat_provider === available_providers.big_blue_button.id) + realm.realm_video_chat_provider === available_providers.big_blue_button.id) || + (available_providers.zoom_server_to_server && + realm.realm_video_chat_provider === available_providers.zoom_server_to_server.id) ) { return true; } diff --git a/web/src/compose_call_ui.ts b/web/src/compose_call_ui.ts index 2fc1b0c5f6..b7c4ec1759 100644 --- a/web/src/compose_call_ui.ts +++ b/web/src/compose_call_ui.ts @@ -2,6 +2,7 @@ import $ from "jquery"; import {z} from "zod"; import * as channel from "./channel.ts"; +import * as compose_banner from "./compose_banner.ts"; import * as compose_call from "./compose_call.ts"; import {get_recipient_label} from "./compose_closed_ui.ts"; import * as compose_ui from "./compose_ui.ts"; @@ -58,11 +59,13 @@ export function generate_and_insert_audio_or_video_call_link( } const available_providers = realm.realm_available_video_chat_providers; + const provider_is_zoom = + available_providers.zoom && realm.realm_video_chat_provider === available_providers.zoom.id; + const provider_is_zoom_server_to_server = + available_providers.zoom_server_to_server && + realm.realm_video_chat_provider === available_providers.zoom_server_to_server.id; - if ( - available_providers.zoom && - realm.realm_video_chat_provider === available_providers.zoom.id - ) { + if (provider_is_zoom || provider_is_zoom_server_to_server) { compose_call.abort_video_callbacks(edit_message_id); const key = edit_message_id ?? ""; @@ -85,16 +88,21 @@ export function generate_and_insert_audio_or_video_call_link( }, error(xhr, status) { compose_call.video_call_xhrs.delete(key); - let parsed; + const parsed = z.object({code: z.string()}).safeParse(xhr.responseJSON); if ( status === "error" && - (parsed = z.object({code: z.string()}).safeParse(xhr.responseJSON)) - .success && + parsed.success && parsed.data.code === "INVALID_ZOOM_TOKEN" ) { current_user.has_zoom_token = false; } - if (status !== "abort") { + if ( + status === "error" && + parsed.success && + parsed.data.code === "UNKNOWN_ZOOM_USER" + ) { + compose_banner.show_unknown_zoom_user_error(current_user.delivery_email); + } else if (status !== "abort") { ui_report.generic_embed_error( $t_html({defaultMessage: "Failed to create video call."}), ); @@ -106,7 +114,7 @@ export function generate_and_insert_audio_or_video_call_link( } }; - if (current_user.has_zoom_token) { + if (current_user.has_zoom_token || provider_is_zoom_server_to_server) { make_zoom_call(); } else { compose_call.zoom_token_callbacks.set(key, make_zoom_call); diff --git a/web/src/state_data.ts b/web/src/state_data.ts index 3f290637a0..aa0c5530b7 100644 --- a/web/src/state_data.ts +++ b/web/src/state_data.ts @@ -288,6 +288,7 @@ export const realm_schema = z.object({ disabled: z.object({name: z.string(), id: z.number()}), jitsi_meet: z.object({name: z.string(), id: z.number()}), zoom: z.optional(z.object({name: z.string(), id: z.number()})), + zoom_server_to_server: z.optional(z.object({name: z.string(), id: z.number()})), big_blue_button: z.optional(z.object({name: z.string(), id: z.number()})), }), realm_avatar_changes_disabled: z.boolean(), diff --git a/web/templates/compose_banner/unknown_zoom_user_error.hbs b/web/templates/compose_banner/unknown_zoom_user_error.hbs new file mode 100644 index 0000000000..28d92204bc --- /dev/null +++ b/web/templates/compose_banner/unknown_zoom_user_error.hbs @@ -0,0 +1,7 @@ +{{#> compose_banner . }} + +{{/compose_banner}} diff --git a/zerver/lib/cache.py b/zerver/lib/cache.py index 25c40fe144..6ba0f093ce 100644 --- a/zerver/lib/cache.py +++ b/zerver/lib/cache.py @@ -678,6 +678,14 @@ def open_graph_description_cache_key(content: bytes, request_url: str) -> str: return f"open_graph_description_path:{hashlib.sha1(request_url.encode()).hexdigest()}" +def zoom_server_access_token_cache_key(account_id: str) -> str: + return f"zoom_server_to_server_access_token:{account_id}" + + +def flush_zoom_server_access_token_cache(account_id: str) -> None: + cache_delete(zoom_server_access_token_cache_key(account_id)) + + def flush_message(*, instance: "Message", **kwargs: object) -> None: message = instance cache_delete(to_dict_cache_key_id(message.id)) diff --git a/zerver/lib/exceptions.py b/zerver/lib/exceptions.py index d67175a728..092ee93ec9 100644 --- a/zerver/lib/exceptions.py +++ b/zerver/lib/exceptions.py @@ -31,6 +31,7 @@ class ErrorCode(Enum): REQUEST_CONFUSING_VAR = auto() INVALID_API_KEY = auto() INVALID_ZOOM_TOKEN = auto() + UNKNOWN_ZOOM_USER = auto() UNAUTHENTICATED_USER = auto() NONEXISTENT_SUBDOMAIN = auto() RATE_LIMIT_HIT = auto() diff --git a/zerver/models/realms.py b/zerver/models/realms.py index 0eae7c4b08..224a5698d6 100644 --- a/zerver/models/realms.py +++ b/zerver/models/realms.py @@ -590,6 +590,12 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub "name": "BigBlueButton", "id": 4, }, + # Only one of the Zoom integrations can be enabled on the server + # at a time, so we use the same name for both. + "zoom_server_to_server": { + "name": "Zoom", + "id": 5, + }, } video_chat_provider = models.PositiveSmallIntegerField( @@ -973,13 +979,21 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub enabled_video_chat_providers: dict[str, VideoChatProviderDict] = {} for provider in self.VIDEO_CHAT_PROVIDERS: if provider == "zoom" and ( - settings.VIDEO_ZOOM_CLIENT_ID is None or settings.VIDEO_ZOOM_CLIENT_SECRET is None + settings.VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID is not None + or settings.VIDEO_ZOOM_CLIENT_ID is None + or settings.VIDEO_ZOOM_CLIENT_SECRET is None ): continue if provider == "big_blue_button" and ( settings.BIG_BLUE_BUTTON_SECRET is None or settings.BIG_BLUE_BUTTON_URL is None ): continue + if provider == "zoom_server_to_server" and ( + settings.VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID is None + or settings.VIDEO_ZOOM_CLIENT_ID is None + or settings.VIDEO_ZOOM_CLIENT_SECRET is None + ): + continue enabled_video_chat_providers[provider] = self.VIDEO_CHAT_PROVIDERS[provider] return enabled_video_chat_providers diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 0fa59e0d08..45f22bc5b6 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -5259,11 +5259,20 @@ paths: - 0 = None - 1 = Jitsi Meet - - 3 = Zoom + - 3 = Zoom (User OAuth integration) - 4 = BigBlueButton + - 5 = Zoom (Server to Server OAuth integration) - **Changes**: None added as an option in Zulip 3.0 (feature level 1) + Note that only one of the [Zoom integrations][zoom-video-calls] can + be configured on a Zulip server. + + **Changes**: In Zulip 10.0 (feature level 353), added the Zoom Server + to Server OAuth option. + + In Zulip 3.0 (feature level 1), added the None option to disable video call UI. + + [zoom-video-calls]: https://zulip.readthedocs.io/en/latest/production/video-calls.html#zoom waiting_period_threshold: type: integer description: | @@ -17368,18 +17377,25 @@ paths: realm_video_chat_provider: type: integer description: | - Present if `realm` is present in `fetch_event_types`. - The configured [video call provider](/help/start-a-call) for the organization. - 0 = None - 1 = Jitsi Meet - - 3 = Zoom + - 3 = Zoom (User OAuth integration) - 4 = BigBlueButton + - 5 = Zoom (Server to Server OAuth integration) - **Changes**: None added as an option in Zulip 3.0 (feature level 1) + Note that only one of the [Zoom integrations][zoom-video-calls] can + be configured on a Zulip server. + + **Changes**: In Zulip 10.0 (feature level 353), added the Zoom Server + to Server OAuth option. + + In Zulip 3.0 (feature level 1), added the None option to disable video call UI. + + [zoom-video-calls]: https://zulip.readthedocs.io/en/latest/production/video-calls.html#zoom realm_jitsi_server_url: type: string nullable: true diff --git a/zerver/tests/test_create_video_call.py b/zerver/tests/test_create_video_call.py index ccac73f581..19360bae03 100644 --- a/zerver/tests/test_create_video_call.py +++ b/zerver/tests/test_create_video_call.py @@ -4,13 +4,15 @@ import orjson import responses from django.core.signing import Signer from django.http import HttpResponseRedirect +from django.test import override_settings from typing_extensions import override from zerver.lib.test_classes import ZulipTestCase from zerver.lib.url_encoding import append_url_query_string -class ZoomVideoCallTest(ZulipTestCase): +@override_settings(VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID=None) +class ZoomVideoCallTestUserAuth(ZulipTestCase): @override def setUp(self) -> None: super().setUp() @@ -221,6 +223,150 @@ class ZoomVideoCallTest(ZulipTestCase): self.assert_json_success(response) +class ZoomVideoCallTestServerAuth(ZulipTestCase): + @override + def setUp(self) -> None: + super().setUp() + self.user = self.example_user("hamlet") + self.login_user(self.user) + self.user_zoom_meeting_url = ( + f"https://api.zoom.us/v2/users/{self.user.delivery_email}/meetings" + ) + + @responses.activate + def test_zoom_invalid_settings(self) -> None: + with self.settings(VIDEO_ZOOM_CLIENT_ID=None): + response = self.client_post("/json/calls/zoom/create") + self.assert_json_error( + response, + "Zoom credentials have not been configured", + ) + + responses.add(responses.POST, "https://zoom.us/oauth/token", status=400) + response = self.client_post("/json/calls/zoom/create") + self.assert_json_error(response, "Invalid Zoom credentials") + + @responses.activate + def test_zoom_invalid_access_token_error(self) -> None: + responses.add( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "token"}, + ) + + responses.add( + responses.POST, + self.user_zoom_meeting_url, + status=400, + json={"code": 124, "message": "API key expired"}, + ) + with self.assertLogs(level="ERROR") as error_log: + response = self.client_post("/json/calls/zoom/create") + self.assertEqual( + error_log.output[0], + "ERROR:root:Unexpected Zoom error 124: API key expired", + ) + self.assert_json_error(response, "Failed to create Zoom call") + + @responses.activate + def test_zoom_unknown_email_error(self) -> None: + responses.add( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "token"}, + ) + + responses.add(responses.POST, self.user_zoom_meeting_url, status=400, json={"code": 1001}) + response = self.client_post("/json/calls/zoom/create") + self.assert_json_error(response, "Unknown Zoom user email") + + @responses.activate + def test_zoom_error_api_response_code_unknown(self) -> None: + responses.add( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "token"}, + ) + + responses.add(responses.POST, self.user_zoom_meeting_url, status=400, json={"code": 300}) + response = self.client_post("/json/calls/zoom/create") + self.assert_json_error(response, "Failed to create Zoom call") + + @responses.activate + def test_zoom_create_video_call(self) -> None: + responses.add( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "token", "expires_in": 3599}, + ) + + responses.add( + responses.POST, + self.user_zoom_meeting_url, + json={"join_url": "example.com"}, + ) + + response = self.client_post("/json/calls/zoom/create", {"is_video_call": "true"}) + self.assertEqual( + responses.calls[-1].request.url, + self.user_zoom_meeting_url, + ) + assert responses.calls[-1].request.body is not None + self.assertEqual( + orjson.loads(responses.calls[-1].request.body), + { + "settings": { + "host_video": True, + "participant_video": True, + }, + "default_password": True, + }, + ) + self.assertEqual( + responses.calls[-1].request.headers["Authorization"], + "Bearer token", + ) + json = self.assert_json_success(response) + self.assertEqual(json["url"], "example.com") + + @responses.activate + def test_zoom_create_audio_call(self) -> None: + responses.add( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "token", "expires_in": 3599}, + ) + + responses.add( + responses.POST, + self.user_zoom_meeting_url, + json={"join_url": "example.com"}, + ) + + response = self.client_post("/json/calls/zoom/create", {"is_video_call": "false"}) + self.assertEqual( + responses.calls[-1].request.url, + self.user_zoom_meeting_url, + ) + assert responses.calls[-1].request.body is not None + self.assertEqual( + orjson.loads(responses.calls[-1].request.body), + { + "settings": { + "host_video": False, + "participant_video": False, + }, + "default_password": True, + }, + ) + self.assertEqual( + responses.calls[-1].request.headers["Authorization"], + "Bearer token", + ) + json = self.assert_json_success(response) + self.assertEqual(json["url"], "example.com") + + class BigBlueButtonVideoCallTest(ZulipTestCase): @override def setUp(self) -> None: diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index 9204f67a4a..b61bf9ceb1 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -1045,19 +1045,45 @@ class RealmTest(ZulipTestCase): zoom_provider_id = Realm.VIDEO_CHAT_PROVIDERS["zoom"]["id"] req = {"video_chat_provider": f"{zoom_provider_id}"} - with self.settings(VIDEO_ZOOM_CLIENT_ID=None): + with self.settings(VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID=None, VIDEO_ZOOM_CLIENT_ID=None): result = self.client_patch("/json/realm", req) self.assert_json_error(result, f"Invalid video_chat_provider {zoom_provider_id}") - with self.settings(VIDEO_ZOOM_CLIENT_SECRET=None): + with self.settings( + VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID=None, VIDEO_ZOOM_CLIENT_SECRET=None + ): result = self.client_patch("/json/realm", req) self.assert_json_error(result, f"Invalid video_chat_provider {zoom_provider_id}") + with self.settings(VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID=None): + result = self.client_patch("/json/realm", req) + self.assert_json_success(result) + self.assertEqual( + get_realm("zulip").video_chat_provider, + zoom_provider_id, + ) + + zoom_server_to_server_provider_id = Realm.VIDEO_CHAT_PROVIDERS["zoom_server_to_server"][ + "id" + ] + req = {"video_chat_provider": f"{zoom_server_to_server_provider_id}"} + with self.settings(VIDEO_ZOOM_CLIENT_ID=None): + result = self.client_patch("/json/realm", req) + self.assert_json_error( + result, f"Invalid video_chat_provider {zoom_server_to_server_provider_id}" + ) + + with self.settings(VIDEO_ZOOM_CLIENT_SECRET=None): + result = self.client_patch("/json/realm", req) + self.assert_json_error( + result, f"Invalid video_chat_provider {zoom_server_to_server_provider_id}" + ) + result = self.client_patch("/json/realm", req) self.assert_json_success(result) self.assertEqual( get_realm("zulip").video_chat_provider, - zoom_provider_id, + zoom_server_to_server_provider_id, ) def test_data_deletion_schedule_when_deactivating_realm(self) -> None: diff --git a/zerver/views/video_calls.py b/zerver/views/video_calls.py index e416739780..2d5cb93e02 100644 --- a/zerver/views/video_calls.py +++ b/zerver/views/video_calls.py @@ -1,6 +1,8 @@ import hashlib import json +import logging import random +from base64 import b64encode from urllib.parse import quote, urlencode, urljoin import requests @@ -22,6 +24,11 @@ from typing_extensions import TypedDict from zerver.actions.video_calls import do_set_zoom_token from zerver.decorator import zulip_login_required +from zerver.lib.cache import ( + cache_with_key, + flush_zoom_server_access_token_cache, + zoom_server_access_token_cache_key, +) from zerver.lib.exceptions import ErrorCode, JsonableError from zerver.lib.outgoing_http import OutgoingSession from zerver.lib.partial import partial @@ -47,6 +54,13 @@ class InvalidZoomTokenError(JsonableError): super().__init__(_("Invalid Zoom access token")) +class UnknownZoomUserError(JsonableError): + code = ErrorCode.UNKNOWN_ZOOM_USER + + def __init__(self) -> None: + super().__init__(_("Unknown Zoom user email")) + + def get_zoom_session(user: UserProfile) -> OAuth2Session: if settings.VIDEO_ZOOM_CLIENT_ID is None: raise JsonableError(_("Zoom credentials have not been configured")) @@ -185,6 +199,73 @@ def make_user_authenticated_zoom_video_call( return json_success(request, data={"url": res.json()["join_url"]}) +@cache_with_key(zoom_server_access_token_cache_key, timeout=3600 - 240) +def get_zoom_server_to_server_access_token(account_id: str) -> str: + if settings.VIDEO_ZOOM_CLIENT_ID is None: + raise JsonableError(_("Zoom credentials have not been configured")) + + client_id = settings.VIDEO_ZOOM_CLIENT_ID.encode("utf-8") + client_secret = str(settings.VIDEO_ZOOM_CLIENT_SECRET).encode("utf-8") + + url = "https://zoom.us/oauth/token" + data = {"grant_type": "account_credentials", "account_id": account_id} + + client_information = client_id + b":" + client_secret + encoded_client = b64encode(client_information).decode("ascii") + headers = {"Host": "zoom.us", "Authorization": f"Basic {encoded_client}"} + + response = VideoCallSession().post(url, data, headers=headers) + if not response.ok: + # {reason: 'Bad request', error: 'invalid_request'} for invalid account ID + # {'reason': 'Invalid client_id or client_secret', 'error': 'invalid_client'} + raise JsonableError(_("Invalid Zoom credentials")) + return response.json()["access_token"] + + +def get_zoom_server_to_server_call( + user: UserProfile, access_token: str, payload: ZoomPayload +) -> str: + email = user.delivery_email + url = f"https://api.zoom.us/v2/users/{email}/meetings" + headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} + response = VideoCallSession().post(url, json=payload, headers=headers) + if not response.ok: + response_dict = response.json() + zoom_api_error_code = response_dict["code"] + if zoom_api_error_code == 1001: + # {code: 1001, message: "User does not exist: {email}"} + raise UnknownZoomUserError + if zoom_api_error_code == 124: + # For the error responses below, we flush any + # cached access token for the Zoom account. + # {code: 124, message: "Invalid access token"} + # {code: 124, message: "Access token is expired"} + account_id = str(settings.VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID) + + # We are managing expiry ourselves, so this shouldn't + # happen. Log an error, and flush the access token from + # the cache, so that future requests should proceed. + logging.error( + "Unexpected Zoom error 124: %s", + response_dict.get("message", str(response_dict)), + ) + flush_zoom_server_access_token_cache(account_id) + raise JsonableError(_("Failed to create Zoom call")) + return response.json()["join_url"] + + +def make_server_authenticated_zoom_video_call( + request: HttpRequest, + user: UserProfile, + *, + payload: ZoomPayload, +) -> HttpResponse: + account_id = str(settings.VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID) + access_token = get_zoom_server_to_server_access_token(account_id) + url = get_zoom_server_to_server_call(user, access_token, payload) + return json_success(request, data={"url": url}) + + @typed_endpoint def make_zoom_video_call( request: HttpRequest, @@ -208,6 +289,8 @@ def make_zoom_video_call( # authentication for all meetings. default_password=True, ) + if settings.VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID is not None: + return make_server_authenticated_zoom_video_call(request, user, payload=payload) return make_user_authenticated_zoom_video_call(request, user, payload=payload) diff --git a/zproject/default_settings.py b/zproject/default_settings.py index 04c6f1919e..e648791219 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -119,6 +119,7 @@ SOCIAL_AUTH_SYNC_ATTRS_DICT: dict[str, dict[str, dict[str, str]]] = {} SSO_APPEND_DOMAIN: str | None = None CUSTOM_HOME_NOT_LOGGED_IN: str | None = None +VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID = get_secret("video_zoom_account_id", development_only=True) VIDEO_ZOOM_CLIENT_ID = get_secret("video_zoom_client_id", development_only=True) VIDEO_ZOOM_CLIENT_SECRET = get_secret("video_zoom_client_secret") diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index 1db220f12c..bbe48dadde 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -709,9 +709,10 @@ SOCIAL_AUTH_SAML_SUPPORT_CONTACT = { ################ ## Video call integrations. ## -## Controls the Zoom video call integration. See: +## Controls the Zoom video call integrations. See: ## https://zulip.readthedocs.io/en/latest/production/video-calls.html # VIDEO_ZOOM_CLIENT_ID = "" +# VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID = "" ## Controls the Jitsi Meet video call integration. By default, the ## integration uses the SaaS https://meet.jit.si server. You can specify diff --git a/zproject/test_extra_settings.py b/zproject/test_extra_settings.py index 9d4fc05145..3d4fea98c8 100644 --- a/zproject/test_extra_settings.py +++ b/zproject/test_extra_settings.py @@ -194,6 +194,7 @@ SOCIAL_AUTH_OIDC_ENABLED_IDPS: dict[str, OIDCIdPConfigDict] = { SOCIAL_AUTH_OIDC_FULL_NAME_VALIDATED = True +VIDEO_ZOOM_SERVER_TO_SERVER_ACCOUNT_ID = "account_id" VIDEO_ZOOM_CLIENT_ID = "client_id" VIDEO_ZOOM_CLIENT_SECRET = "client_secret"