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

Zoom Updates #681

Merged
merged 1 commit into from
Sep 24, 2024
Merged
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
20 changes: 19 additions & 1 deletion backend/src/appointment/controller/apis/zoom_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import os
import time

import sentry_sdk
Expand All @@ -14,6 +15,15 @@ class ZoomClient:
OAUTH_REQUEST_URL = 'https://api.zoom.us/v2'

SCOPES = ['user:read', 'user_info:read', 'meeting:write']
NEW_SCOPES = [
'meeting:read:meeting',
'meeting:write:meeting',
'meeting:update:meeting',
'meeting:delete:meeting',
'meeting:write:invite_links',
'user:read:email',
'user:read:user',
]

client: OAuth2Session | None = None
subscriber_id: int | None = None
Expand All @@ -24,6 +34,14 @@ def __init__(self, client_id, client_secret, callback_url):
self.callback_url = callback_url
self.subscriber_id = None
self.client = None
self.use_new_scopes = os.getenv('ZOOM_API_NEW_APP', False) == 'True'

@property
def scopes(self):
"""Returns the appropriate scopes"""
if self.use_new_scopes:
return self.NEW_SCOPES
return self.SCOPES

def check_expiry(self, token: dict | None):
"""Checks expires_at and if expired sets expires_in to a negative number to trigger refresh"""
Expand All @@ -50,7 +68,7 @@ def setup(self, subscriber_id=None, token=None):
self.client = OAuth2Session(
self.client_id,
redirect_uri=self.callback_url,
scope=self.SCOPES,
scope=self.scopes,
auto_refresh_url=self.OAUTH_TOKEN_URL,
auto_refresh_kwargs={
'client_id': self.client_id,
Expand Down
16 changes: 16 additions & 0 deletions backend/src/appointment/controller/zoom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from sqlalchemy.orm import Session

from appointment.database import repo, models
from appointment.database.models import ExternalConnectionType


def disconnect(db: Session, subscriber_id: int, type_id: str) -> bool:
"""Disconnects a zoom external connection from a given subscriber id and zoom type id"""
repo.external_connection.delete_by_type(db, subscriber_id, ExternalConnectionType.zoom, type_id)
schedules = repo.schedule.get_by_subscriber(db, subscriber_id)
for schedule in schedules:
if schedule.meeting_link_provider == models.MeetingLinkProviderType.zoom:
schedule.meeting_link_provider = models.MeetingLinkProviderType.none
db.add(schedule)
db.commit()
return True
16 changes: 16 additions & 0 deletions backend/src/appointment/database/repo/external_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,19 @@ def get_subscriber_by_fxa_uid(db: Session, type_id: str):
return result.owner

return None


def get_subscriber_by_zoom_user_id(db: Session, type_id: str):
"""Return a subscriber from a zoom user id"""
query = (
db.query(models.ExternalConnections)
.filter(models.ExternalConnections.type == models.ExternalConnectionType.zoom)
.filter(models.ExternalConnections.type_id == type_id)
)

result = query.first()

if result is not None:
return result.owner

return None
39 changes: 38 additions & 1 deletion backend/src/appointment/dependencies/zoom.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import hashlib
import hmac
import logging
import os

from fastapi import Depends
from fastapi import Depends, Request

from .auth import get_subscriber
from ..controller.apis.zoom_client import ZoomClient
Expand All @@ -25,3 +27,38 @@ def get_zoom_client(subscriber: Subscriber = Depends(get_subscriber)):
raise e

return _zoom_client


async def get_webhook_auth(request: Request):
data = await request.json()
event = data.get('event')

if not event or event != 'app_deauthorized':
return None

signature = request.headers.get('x-zm-signature')
signature_timestamp = request.headers.get('x-zm-request-timestamp')
key = os.getenv('ZOOM_API_SECRET')

if not signature or not signature_timestamp or not key:
return None

# Grab the body, and get encoding!
# Body is encoded in bytes so we'll need to decode it and re-encode it...
body = await request.body()
key = bytes(key, 'UTF-8')
message = bytes(f'v0:{signature_timestamp}:{body.decode('UTF-8')}', 'UTF-8')
hash = hmac.new(key, message, hashlib.sha256).hexdigest()
hash = f'v0={hash}'

if hash != signature:
return None

payload = data.get('payload', {})
user_id = payload.get('user_id')
deauthorized_at = payload.get('deauthorization_time')

if not user_id or not deauthorized_at:
return None

return payload
40 changes: 35 additions & 5 deletions backend/src/appointment/routes/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

import requests
import sentry_sdk
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session

from ..controller import auth, data
from ..controller import auth, data, zoom
from ..controller.apis.fxa_client import FxaClient
from ..database import repo, models, schemas
from ..database.models import ExternalConnectionType
from ..dependencies.database import get_db
from ..dependencies.fxa import get_webhook_auth, get_fxa_client
from ..dependencies.fxa import get_webhook_auth as get_webhook_auth_fxa, get_fxa_client
from ..dependencies.zoom import get_webhook_auth as get_webhook_auth_zoom
from ..exceptions.account_api import AccountDeletionSubscriberFail
from ..exceptions.fxa_api import MissingRefreshTokenException

Expand All @@ -19,14 +21,14 @@
@router.post('/fxa-process')
def fxa_process(
db: Session = Depends(get_db),
decoded_token: dict = Depends(get_webhook_auth),
decoded_token: dict = Depends(get_webhook_auth_fxa),
fxa_client: FxaClient = Depends(get_fxa_client),
):
"""Main for webhooks regarding fxa"""

subscriber: models.Subscriber = repo.external_connection.get_subscriber_by_fxa_uid(db, decoded_token.get('sub'))
if not subscriber:
logging.warning('Webhook event received for non-existent user.')
logging.warning('FXA webhook event received for non-existent user.')
return

subscriber_external_connection = subscriber.get_external_connection(models.ExternalConnectionType.fxa)
Expand Down Expand Up @@ -86,3 +88,31 @@ def fxa_process(

case _:
logging.warning(f'Ignoring event {event}')


@router.post('/zoom-deauthorization')
def zoom_deauthorization(
request: Request,
db: Session = Depends(get_db),
webhook_payload: dict | None = Depends(get_webhook_auth_zoom)
):
if not webhook_payload:
logging.warning('Invalid zoom webhook event received.')
return

user_id = webhook_payload.get('user_id')

subscriber = repo.external_connection.get_subscriber_by_zoom_user_id(
db,
user_id
)

if not subscriber:
logging.warning('Zoom webhook event received for non-existent user.')
return

try:
zoom.disconnect(db, subscriber.id, user_id)
except Exception as ex:
sentry_sdk.capture_exception(ex)
logging.error(f'Error disconnecting zoom connection: {ex}')
9 changes: 2 additions & 7 deletions backend/src/appointment/routes/zoom.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session

from ..controller import zoom
from ..controller.apis.zoom_client import ZoomClient
from ..controller.auth import sign_url
from ..database import repo, schemas, models
Expand Down Expand Up @@ -115,13 +116,7 @@ def disconnect_account(
zoom_connection = subscriber.get_external_connection(ExternalConnectionType.zoom)

if zoom_connection:
repo.external_connection.delete_by_type(db, subscriber.id, zoom_connection.type, zoom_connection.type_id)
schedules = repo.schedule.get_by_subscriber(db, subscriber.id)
for schedule in schedules:
if schedule.meeting_link_provider == models.MeetingLinkProviderType.zoom:
schedule.meeting_link_provider = models.MeetingLinkProviderType.none
db.add(schedule)
db.commit()
zoom.disconnect(db, subscriber.id, zoom_connection.type_id)
else:
return False

Expand Down
2 changes: 2 additions & 0 deletions backend/src/appointment/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ def normalize_secrets():

os.environ['ZOOM_AUTH_CLIENT_ID'] = secrets.get('client_id')
os.environ['ZOOM_AUTH_SECRET'] = secrets.get('secret')
os.environ['ZOOM_API_SECRET'] = secrets.get('api_secret')
os.environ['ZOOM_API_NEW_APP'] = secrets.get('api_new_app', False)

fxa_secrets = os.getenv('FXA_SECRETS')

Expand Down
154 changes: 154 additions & 0 deletions backend/test/integration/test_webhooks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import datetime
import hashlib
import hmac
import json
import os

import pytest
from freezegun import freeze_time
from appointment.database import models, repo
from appointment.database.models import ExternalConnectionType

from appointment.dependencies.fxa import get_webhook_auth
from defines import FXA_CLIENT_PATCH
Expand Down Expand Up @@ -167,3 +173,151 @@ def override_get_webhook_auth():
assert repo.subscriber.get(db, subscriber.id) is None
assert repo.calendar.get(db, calendar.id) is None
assert repo.appointment.get(db, appointment.id) is None

class TestZoomWebhooks:
@pytest.fixture
def setup_deauthorization(self, make_pro_subscriber, make_external_connections):
zoom_user_id = 'z9jkdsfsdfjhdkfjQ'

request_body = {
"event": "app_deauthorized",
"payload": {
"account_id": "EabCDEFghiLHMA",
"user_id": zoom_user_id,
"signature": "827edc3452044f0bc86bdd5684afb7d1e6becfa1a767f24df1b287853cf73000",
"deauthorization_time": "2019-06-17T13:52:28.632Z",
"client_id": "ADZ9k9bTWmGUoUbECUKU_a"
}
}

zoom_signature = 'v0=cc6857f5b05fea4fb0f2057912c14a68996cfcf36a4267c65f15a3e9f1602477'
zoom_timestamp = "2019-06-17T13:52:28.632Z"
request_headers = {
'x-zm-signature': zoom_signature,
'x-zm-request-timestamp': zoom_timestamp
}

fake_secret = 'cake'
os.environ['ZOOM_API_SECRET'] = fake_secret

subscriber = make_pro_subscriber()
external_connection = make_external_connections(
subscriber_id=subscriber.id,
type=models.ExternalConnectionType.zoom.value,
type_id=zoom_user_id
)

return request_body, request_headers, subscriber, external_connection

def test_deauthorization(self, with_client, with_db, setup_deauthorization):
"""Test a successful deauthorization (i.e. deleting the zoom connection)"""
request_body, request_headers, subscriber, external_connection = setup_deauthorization
with with_db() as db:
assert subscriber
assert external_connection

db.add(subscriber)
db.add(external_connection)

zoom_user_id = external_connection.type_id

response = with_client.post(
'/webhooks/zoom-deauthorization',
json=request_body,
headers=request_headers
)
assert response.status_code == 200, response.text

db.refresh(subscriber)
external_connection = repo.external_connection.get_by_type(
db,
subscriber.id,
type=ExternalConnectionType.zoom,
type_id=zoom_user_id
)

assert subscriber
assert not external_connection

def test_deauthorization_silent_fail_due_to_no_connection(self, with_client, with_db, setup_deauthorization):
"""Test that a missing zoom connection doesn't crash the webhook"""
request_body, request_headers, subscriber, external_connection = setup_deauthorization

with with_db() as db:
assert subscriber
assert external_connection

db.add(subscriber)
db.add(external_connection)

# Remove our external connection
db.delete(external_connection)
db.commit()

response = with_client.post(
'/webhooks/zoom-deauthorization',
json=request_body,
headers=request_headers
)
assert response.status_code == 200, response.text

def test_deauthorization_silent_fail_due_to_no_user(self, with_client, with_db, setup_deauthorization):
"""Test that a missing subscriber doesn't crash the webhook"""
request_body, request_headers, subscriber, external_connection = setup_deauthorization

with with_db() as db:
assert subscriber
assert external_connection

db.add(subscriber)
db.add(external_connection)

# Remove our external connection AND subscriber
db.delete(external_connection)
db.delete(subscriber)
db.commit()

response = with_client.post(
'/webhooks/zoom-deauthorization',
json=request_body,
headers=request_headers
)
assert response.status_code == 200, response.text

def test_deauthorization_with_invalid_webhook(self, with_client, with_db):
"""Test that an invalid request doesn't crash the webhook"""
response = with_client.post(
'/webhooks/zoom-deauthorization',
json={
'event': 'im-a-fake-event-woo!'
},
)
assert response.status_code == 200, response.text

def test_deauthorization_with_invalid_webhook_headers(self, with_client, with_db, setup_deauthorization):
"""Test that a valid response body with invalid headers doesn't remove the connection"""
request_body, request_headers, subscriber, external_connection = setup_deauthorization

with with_db() as db:
assert subscriber
assert external_connection

db.add(subscriber)
db.add(external_connection)

response = with_client.post(
'/webhooks/zoom-deauthorization',
json=request_body,
headers={
'x-zm-signature': 'bad-signature',
'x-zm-signature-timestamp': 'bad-timestamp'
}
)
assert response.status_code == 200, response.text

# Ensure that our connection still exists
db.refresh(subscriber)
db.refresh(external_connection)

assert subscriber
assert external_connection