diff --git a/vc_zoom/README.md b/vc_zoom/README.md index f1c9b67..0487d5c 100644 --- a/vc_zoom/README.md +++ b/vc_zoom/README.md @@ -2,15 +2,19 @@ ## Features - * Creating Zoom meetings from Indico; - * Sharing Zoom meetings between more than one Indico event; - * Creating meetings on behalf of others; - * Changes of host possible after creation; + * Creating Zoom meetings from Indico + * Sharing Zoom meetings between more than one Indico event + * Creating meetings on behalf of others + * Changes of host possible after creation * Protection of Zoom link (only logged in, everyone or no one) - * Webinar mode; + * Webinar mode ## Changelog +### 3.2.3 + +- Support Zoom's Server-to-Server OAuth in addition to the (deprecated) JWT + ### 3.2.2 - Correctly show current "Mute video (host)" status when editing zoom meeting @@ -118,9 +122,23 @@ These are the most relevant configuration options: * **Webhook token** (optional) - the token which Zoom requests will authenticate with (get it from Zoom Marketplace) -### Zoom API key/secret (JWT) +### Zoom Server-to-Server OAuth -To obtain API key and API secret, please visit [https://marketplace.zoom.us/docs/guides/auth/jwt](https://marketplace.zoom.us/docs/guides/auth/jwt). +See the [zoom documentation](https://marketplace.zoom.us/docs/guides/build/server-to-server-oauth-app/#create-a-server-to-server-oauth-app) on how to get the credentials for authenticating with the Zoom servers. + +The scopes to select when creating the app are: + +- `meeting:read:admin` +- `meeting:write:admin` +- `user:read:admin` +- `webinar:read:admin` (optional, only needed when using webinars) +- `webinar:write:admin` (optional, only needed when using webinars) + + +### Zoom API key/secret (JWT, deprecated) + +Zoom deprecated JWTs in June 2023, existing ones still work but no new ones can be created. +As soon as Zoom fully dropped them, JWT support will also be removed from this plugin. ## Intellectual Property diff --git a/vc_zoom/indico_vc_zoom/api/client.py b/vc_zoom/indico_vc_zoom/api/client.py index e73c424..e54cf9c 100644 --- a/vc_zoom/indico_vc_zoom/api/client.py +++ b/vc_zoom/indico_vc_zoom/api/client.py @@ -8,10 +8,17 @@ import time import jwt +import requests from pytz import utc from requests import Session from requests.exceptions import HTTPError +from indico.core.cache import make_scoped_cache +from indico.util.string import crc32 + + +token_cache = make_scoped_cache('zoom-api-token') + def format_iso_dt(d): """Convert a datetime objects to a UTC-based string. @@ -38,26 +45,40 @@ class APIException(Exception): pass +class ZoomSession(Session): + def __init__(self, zoom_plugin_config): + super().__init__() + self.__zoom_plugin_config = zoom_plugin_config + self.__set_zoom_headers(self.__get_token()) + + def __get_token(self, *, force=False): + return get_zoom_token(self.__zoom_plugin_config, force=force)[0] + + def __set_zoom_headers(self, token): + self.headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {token}' + } + + def request(self, *args, **kwargs): + from indico_vc_zoom.plugin import ZoomPlugin + resp = super().request(*args, **kwargs) + if resp.status_code == 401: + ZoomPlugin.logger.warn('Request failed with invalid token; getting a new one') + self.__set_zoom_headers(self.__get_token(force=True)) + resp = super().request(*args, **kwargs) + return resp + + class BaseComponent: def __init__(self, base_uri, config, timeout): self.base_uri = base_uri self.config = config self.timeout = timeout - @property - def token(self): - header = {'alg': 'HS256', 'typ': 'JWT'} - payload = {'iss': self.config['api_key'], 'exp': int(time.time() + 3600)} - return jwt.encode(payload, self.config['api_secret'], algorithm='HS256', headers=header) - @property def session(self): - session = Session() - session.headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {self.token}' - } - return session + return ZoomSession(self.config) class MeetingComponent(BaseComponent): @@ -151,17 +172,23 @@ class ZoomClient: 'webinar': WebinarComponent } - def __init__(self, api_key, api_secret, timeout=15): + def __init__(self, api_key, api_secret, account_id, client_id, client_secret, timeout=15): """Create a new Zoom client. :param api_key: the Zoom JWT API key :param api_secret: the Zoom JWT API Secret + :param account_id: the Zoom Server OAuth Account ID + :param client_id: the Zoom Server OAuth Client ID + :param client_secret: the Zoom Server OAuth Client Secret :param timeout: the time out to use for API requests """ # Setup the config details config = { 'api_key': api_key, - 'api_secret': api_secret + 'api_secret': api_secret, + 'account_id': account_id, + 'client_id': client_id, + 'client_secret': client_secret, } # Instantiate the components @@ -191,7 +218,10 @@ class ZoomIndicoClient: from indico_vc_zoom.plugin import ZoomPlugin self.client = ZoomClient( ZoomPlugin.settings.get('api_key'), - ZoomPlugin.settings.get('api_secret') + ZoomPlugin.settings.get('api_secret'), + ZoomPlugin.settings.get('account_id'), + ZoomPlugin.settings.get('client_id'), + ZoomPlugin.settings.get('client_secret'), ) def create_meeting(self, user_id, **kwargs): @@ -223,3 +253,45 @@ class ZoomIndicoClient: if resp.status_code == 404 and silent: return None return _handle_response(resp) + + +def get_zoom_token(config, *, force=False): + from indico_vc_zoom.plugin import ZoomPlugin + + account_id = config['account_id'] + client_id = config['client_id'] + client_secret = config['client_secret'] + + if account_id and client_id and client_secret: + ZoomPlugin.logger.debug(f'Using Server-to-Server-OAuth ({force=})') + hash_key = '-'.join((account_id, client_id, client_secret)) + cache_key = f'token-{crc32(hash_key)}' + if not force and (token_data := token_cache.get(cache_key)): + expires_in = int(token_data['expires_at'] - time.time()) + ZoomPlugin.logger.debug('Using token from cache (%s, %ds remaining)', cache_key, expires_in) + return token_data['access_token'], token_data['expires_at'] + try: + resp = requests.post( + 'https://zoom.us/oauth/token', + params={'grant_type': 'account_credentials', 'account_id': account_id}, + auth=(client_id, client_secret) + ) + resp.raise_for_status() + except HTTPError as exc: + ZoomPlugin.logger.error('Could not get zoom token: %s', exc.response.text if exc.response else exc) + raise Exception('Could not get zoom token; please contact an admin if this problem persists.') + token_data = resp.json() + assert 'access_token' in token_data + ZoomPlugin.logger.debug('Got new token from Zoom (expires_in=%s, scope=%s)', token_data['expires_in'], + token_data['scope']) + expires_at = int(time.time() + token_data['expires_in']) + token_data.setdefault('expires_at', expires_at) # zoom doesn't include this. wtf. + token_cache.set(cache_key, token_data, token_data['expires_in']) + return token_data['access_token'], token_data['expires_at'] + elif config['api_key'] and config['api_secret']: + ZoomPlugin.logger.warning('Using JWT (deprecated)') + header = {'alg': 'HS256', 'typ': 'JWT'} + payload = {'iss': config['api_key'], 'exp': int(time.time() + 3600)} + return jwt.encode(payload, config['api_secret'], algorithm='HS256', headers=header), None + else: + raise Exception('Zoom authentication not configured') diff --git a/vc_zoom/indico_vc_zoom/plugin.py b/vc_zoom/indico_vc_zoom/plugin.py index 07fbd70..9de824c 100644 --- a/vc_zoom/indico_vc_zoom/plugin.py +++ b/vc_zoom/indico_vc_zoom/plugin.py @@ -42,7 +42,8 @@ from indico_vc_zoom.util import (UserLookupMode, ZoomMeetingType, fetch_zoom_mee class PluginSettingsForm(VCPluginSettingsFormBase): _fieldsets = [ - (_('API Credentials'), ['api_key', 'api_secret', 'webhook_token']), + (_('API Credentials (Server-to-Server OAuth)'), ['account_id', 'client_id', 'client_secret', 'webhook_token']), + (_('API Credentials (Legacy JWT, deprecated)'), ['api_key', 'api_secret']), (_('Zoom Account'), ['user_lookup_mode', 'email_domains', 'authenticators', 'enterprise_domain', 'allow_webinars', 'phone_link']), (_('Room Settings'), ['mute_audio', 'mute_host_video', 'mute_participant_video', 'join_before_host', @@ -51,9 +52,11 @@ class PluginSettingsForm(VCPluginSettingsFormBase): (_('Access'), ['managers', 'acl']) ] - api_key = StringField(_('API Key'), [DataRequired()]) - - api_secret = IndicoPasswordField(_('API Secret'), [DataRequired()], toggle=True) + api_key = StringField(_('API Key'), []) + api_secret = IndicoPasswordField(_('API Secret'), [], toggle=True) + account_id = StringField(_('Account ID'), []) + client_id = StringField(_('Client ID'), []) + client_secret = IndicoPasswordField(_('Client Secret'), [], toggle=True) webhook_token = IndicoPasswordField(_('Webhook Token'), toggle=True, description=_("Specify Zoom's webhook token if you want live updates")) @@ -135,6 +138,9 @@ class ZoomPlugin(VCPluginMixin, IndicoPlugin): default_settings = VCPluginMixin.default_settings | { 'api_key': '', 'api_secret': '', + 'account_id': '', + 'client_id': '', + 'client_secret': '', 'webhook_token': '', 'user_lookup_mode': UserLookupMode.email_domains, 'email_domains': [], diff --git a/vc_zoom/indico_vc_zoom/task.py b/vc_zoom/indico_vc_zoom/task.py index 953f3fa..23fc795 100644 --- a/vc_zoom/indico_vc_zoom/task.py +++ b/vc_zoom/indico_vc_zoom/task.py @@ -5,12 +5,17 @@ # them and/or modify them under the terms of the MIT License; # see the LICENSE file for more details. +import time + +from celery.schedules import crontab from requests.exceptions import HTTPError from sqlalchemy.orm.attributes import flag_modified from indico.core.celery import celery from indico.core.db import db +from indico_vc_zoom.api.client import get_zoom_token + def update_state_log(log_entry, failed): log_entry.data['State'] = 'failed' if failed else 'succeeded' @@ -35,3 +40,22 @@ def refresh_meetings(vc_rooms, obj, log_entry=None): if log_entry: update_state_log(log_entry, failed) db.session.commit() + + +@celery.periodic_task(run_every=crontab(minute='*/5'), plugin='vc_zoom') +def refresh_token(threshold=600): + from indico_vc_zoom.plugin import ZoomPlugin + + config = ZoomPlugin.settings.get_all() + if not all(config[x] for x in ('account_id', 'client_id', 'client_secret')): + # not using oauth -> nothing to do + return + + expires_at = get_zoom_token(config)[1] + expires_in = int(expires_at - time.time()) + if expires_in > threshold: + ZoomPlugin.logger.debug('Token expires in %ds, not renewing yet', expires_in) + return + + ZoomPlugin.logger.info('Token expires in %ds, getting a new one', expires_in) + get_zoom_token(config, force=True) diff --git a/vc_zoom/setup.cfg b/vc_zoom/setup.cfg index ce8171c..b52a49e 100644 --- a/vc_zoom/setup.cfg +++ b/vc_zoom/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = indico-plugin-vc-zoom -version = 3.2.2 +version = 3.2.3 description = Zoom video-conferencing plugin for Indico long_description = file: README.md long_description_content_type = text/markdown; charset=UTF-8; variant=GFM