Skip to content

Commit

Permalink
Add a new env variable that uses the new granular permissions for Zoo…
Browse files Browse the repository at this point in the history
…m. (#681)

Add Zoom deauthorization webhook

Pipe the secrets in from secret manager
  • Loading branch information
MelissaAutumn authored Sep 24, 2024
1 parent d71ecef commit 9594102
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 9594102

Please sign in to comment.