VC/Zoom: Support server-to-server oauth (#193)

This commit is contained in:
Adrian 2023-06-16 08:32:11 +02:00 committed by Adrian Moennich
parent 817d44b513
commit c192c4a8a1
5 changed files with 147 additions and 27 deletions

View File

@ -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

View File

@ -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')

View File

@ -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': [],

View File

@ -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)

View File

@ -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