mirror of
https://github.com/lucaspalomodevelop/indico-plugins.git
synced 2026-03-13 07:29:39 +00:00
VC/Zoom: Support server-to-server oauth (#193)
This commit is contained in:
parent
817d44b513
commit
c192c4a8a1
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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': [],
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user