Skip to content

Commit

Permalink
Add a new env variable that uses the new granular permissions for Zoom.
Browse files Browse the repository at this point in the history
Add Zoom deauthorization webhook

Pipe the secrets in from secret manager
  • Loading branch information
MelissaAutumn committed Sep 18, 2024
1 parent d71ecef commit 1ccb721
Show file tree
Hide file tree
Showing 8 changed files with 282 additions and 14 deletions.
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

0 comments on commit 1ccb721

Please sign in to comment.