diff --git a/airbyte-integrations/connectors/source-zoom/Dockerfile b/airbyte-integrations/connectors/source-zoom/Dockerfile index d8781d6a788b..2fcce7c308da 100644 --- a/airbyte-integrations/connectors/source-zoom/Dockerfile +++ b/airbyte-integrations/connectors/source-zoom/Dockerfile @@ -36,5 +36,5 @@ ENV AIRBYTE_ENTRYPOINT "python /airbyte/integration_code/main.py" ENTRYPOINT ["python", "/airbyte/integration_code/main.py"] -LABEL io.airbyte.version=0.1.1 +LABEL io.airbyte.version=1.0.0 LABEL io.airbyte.name=airbyte/source-zoom diff --git a/airbyte-integrations/connectors/source-zoom/integration_tests/invalid_config.json b/airbyte-integrations/connectors/source-zoom/integration_tests/invalid_config.json index 6a603fda8000..72a8ba046730 100644 --- a/airbyte-integrations/connectors/source-zoom/integration_tests/invalid_config.json +++ b/airbyte-integrations/connectors/source-zoom/integration_tests/invalid_config.json @@ -1,3 +1,5 @@ { - "jwt_token": "dummy" + "client_id": "client_id", + "client_secret": "client_secret", + "authorization_endpoint": "https://zoom.us/oauth/token" } diff --git a/airbyte-integrations/connectors/source-zoom/integration_tests/sample_config.json b/airbyte-integrations/connectors/source-zoom/integration_tests/sample_config.json index f875ad8416c6..fa709018b12f 100644 --- a/airbyte-integrations/connectors/source-zoom/integration_tests/sample_config.json +++ b/airbyte-integrations/connectors/source-zoom/integration_tests/sample_config.json @@ -1,3 +1,6 @@ { - "jwt_token": "abcd" + "account_id": "account_id", + "client_id": "client_id", + "client_secret": "client_secret", + "authorization_endpoint": "https://zoom.us/oauth/token" } diff --git a/airbyte-integrations/connectors/source-zoom/metadata.yaml b/airbyte-integrations/connectors/source-zoom/metadata.yaml index 0f7465ef7a7b..9ab2e15b47eb 100644 --- a/airbyte-integrations/connectors/source-zoom/metadata.yaml +++ b/airbyte-integrations/connectors/source-zoom/metadata.yaml @@ -2,7 +2,7 @@ data: connectorSubtype: api connectorType: source definitionId: cbfd9856-1322-44fb-bcf1-0b39b7a8e92e - dockerImageTag: 0.1.1 + dockerImageTag: 1.0.0 dockerRepository: airbyte/source-zoom githubIssueLabel: source-zoom icon: zoom.svg diff --git a/airbyte-integrations/connectors/source-zoom/source_zoom/components.py b/airbyte-integrations/connectors/source-zoom/source_zoom/components.py new file mode 100644 index 000000000000..8432882e824e --- /dev/null +++ b/airbyte-integrations/connectors/source-zoom/source_zoom/components.py @@ -0,0 +1,90 @@ +import base64 +import requests +import time + +from dataclasses import dataclass +from http import HTTPStatus +from typing import Any, Mapping, Union + +from airbyte_cdk.sources.declarative.auth.declarative_authenticator import NoAuth +from airbyte_cdk.sources.declarative.interpolation import InterpolatedString +from airbyte_cdk.sources.declarative.types import Config +from requests import HTTPError + +# https://developers.zoom.us/docs/internal-apps/s2s-oauth/#successful-response +# The Bearer token generated by server-to-server token will expire in one hour +BEARER_TOKEN_EXPIRES_IN = 3590 + + +class SingletonMeta(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + """ + Possible changes to the value of the `__init__` argument do not affect + the returned instance. + """ + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] + + +@dataclass +class ServerToServerOauthAuthenticator(NoAuth): + config: Config + account_id: Union[InterpolatedString, str] + client_id: Union[InterpolatedString, str] + client_secret: Union[InterpolatedString, str] + authorization_endpoint: Union[InterpolatedString, str] + + _instance = None + _generate_token_time = 0 + _access_token = None + _grant_type = "account_credentials" + + def __post_init__(self, parameters: Mapping[str, Any]): + self._account_id = InterpolatedString.create(self.account_id, parameters=parameters).eval(self.config) + self._client_id = InterpolatedString.create(self.client_id, parameters=parameters).eval(self.config) + self._client_secret = InterpolatedString.create(self.client_secret, parameters=parameters).eval(self.config) + self._authorization_endpoint = InterpolatedString.create(self.authorization_endpoint, parameters=parameters).eval(self.config) + + def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: + """Attach the page access token to params to authenticate on the HTTP request""" + if self._access_token is None or ((time.time() - self._generate_token_time) > BEARER_TOKEN_EXPIRES_IN): + self._generate_token_time = time.time() + self._access_token = self.generate_access_token() + headers = { + "Authorization": f"Bearer {self._access_token}", + 'Content-type': 'application/json' + } + request.headers.update(headers) + + return request + + @property + def auth_header(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.token}", + 'Content-type': 'application/json' + } + + @property + def token(self) -> str: + return self._access_token + + def generate_access_token(self) -> str: + self._generate_token_time = time.time() + try: + token = base64.b64encode(f'{self._client_id}:{self._client_secret}'.encode('ascii')).decode('utf-8') + headers = {'Authorization': f'Basic {token}', + 'Content-type': 'application/json'} + rest = requests.post( + url=f"{self._authorization_endpoint}?grant_type={self._grant_type}&account_id={self._account_id}", + headers=headers + ) + if rest.status_code != HTTPStatus.OK: + raise HTTPError(rest.text) + return rest.json().get("access_token") + except Exception as e: + raise Exception(f"Error while generating access token: {e}") from e diff --git a/airbyte-integrations/connectors/source-zoom/source_zoom/manifest.yaml b/airbyte-integrations/connectors/source-zoom/source_zoom/manifest.yaml index 92c993f37ef9..21ec25e9ff0b 100644 --- a/airbyte-integrations/connectors/source-zoom/source_zoom/manifest.yaml +++ b/airbyte-integrations/connectors/source-zoom/source_zoom/manifest.yaml @@ -1,12 +1,17 @@ version: "0.29.0" definitions: + # Server to Server Oauth Authenticator requester: - url_base: "https://api.zoom.us/v2/" + url_base: "https://api.zoom.us/v2" http_method: "GET" authenticator: - type: BearerAuthenticator - api_token: "{{ config['jwt_token'] }}" + class_name: source_zoom.components.ServerToServerOauthAuthenticator + client_id: "{{ config['client_id'] }}" + account_id: "{{ config['account_id'] }}" + client_secret: "{{ config['client_secret'] }}" + authorization_endpoint: "{{ config['authorization_endpoint'] }}" + grant_type: "account_credentials" zoom_paginator: type: DefaultPaginator diff --git a/airbyte-integrations/connectors/source-zoom/source_zoom/spec.yaml b/airbyte-integrations/connectors/source-zoom/source_zoom/spec.yaml index a8170e08c3b7..f49664ace39e 100644 --- a/airbyte-integrations/connectors/source-zoom/source_zoom/spec.yaml +++ b/airbyte-integrations/connectors/source-zoom/source_zoom/spec.yaml @@ -4,10 +4,26 @@ connectionSpecification: title: Zoom Spec type: object required: - - jwt_token + - account_id + - client_id + - client_secret + - authorization_endpoint additionalProperties: true properties: - jwt_token: + account_id: type: string - description: JWT Token + order: 0 + description: "The account ID for your Zoom account. You can find this in the Zoom Marketplace under the \"Manage\" tab for your app." + client_id: + type: string + order: 1 + description: "The client ID for your Zoom app. You can find this in the Zoom Marketplace under the \"Manage\" tab for your app." + client_secret: + type: string + order: 2 + description: "The client secret for your Zoom app. You can find this in the Zoom Marketplace under the \"Manage\" tab for your app." airbyte_secret: true + authorization_endpoint: + type: string + order: 3 + default: "https://zoom.us/oauth/token" diff --git a/airbyte-integrations/connectors/source-zoom/unit_tests/test_zoom_authenticator.py b/airbyte-integrations/connectors/source-zoom/unit_tests/test_zoom_authenticator.py new file mode 100755 index 000000000000..d1e810c6a960 --- /dev/null +++ b/airbyte-integrations/connectors/source-zoom/unit_tests/test_zoom_authenticator.py @@ -0,0 +1,56 @@ +import base64 +from http import HTTPStatus +import unittest +import requests +import requests_mock +from source_zoom.components import ServerToServerOauthAuthenticator + + +class TestOAuthClient(unittest.TestCase): + def test_generate_access_token(self): + except_access_token = "rc-test-token" + except_token_response = {"access_token": except_access_token} + + config = { + "account_id": "rc-asdfghjkl", + "client_id": "rc-123456789", + "client_secret": "rc-test-secret", + "authorization_endpoint": "https://example.zoom.com/oauth/token", + "grant_type": "account_credentials" + } + parameters = config + client = ServerToServerOauthAuthenticator(config=config, + account_id=config["account_id"], + client_id=config["client_id"], + client_secret=config["client_secret"], + grant_type=config["grant_type"], + authorization_endpoint=config["authorization_endpoint"], + parameters=parameters) + + # Encode the client credentials in base64 + token = base64.b64encode(f'{config.get("client_id")}:{config.get("client_secret")}'.encode('ascii')).decode('utf-8') + + # Define the headers that should be sent in the request + headers = {'Authorization': f'Basic {token}', + 'Content-type': 'application/json'} + + # Define the URL containing the grant_type and account_id as query parameters + url = f'{config.get("authorization_endpoint")}?grant_type={config.get("grant_type")}&account_id={config.get("account_id")}' + + with requests_mock.Mocker() as m: + # Mock the requests.post call with the expected URL, headers and token response + m.post(url, json=except_token_response, request_headers=headers, status_code=HTTPStatus.OK) + + # Call the generate_access_token function and assert it returns the expected access token + self.assertEqual(client.generate_access_token(), except_access_token) + + # Test case when the endpoint has some error, like a timeout + with requests_mock.Mocker() as m: + m.post(url, exc=requests.exceptions.RequestException) + with self.assertRaises(Exception) as cm: + client.generate_access_token() + self.assertIn("Error while generating access token", str(cm.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/integrations/sources/zoom.md b/docs/integrations/sources/zoom.md index aeba329c79ec..f7b9764239ae 100644 --- a/docs/integrations/sources/zoom.md +++ b/docs/integrations/sources/zoom.md @@ -53,15 +53,22 @@ Please [create an issue](https://github.com/airbytehq/airbyte/issues) if you see ### Requirements -* Zoom JWT Token +* Zoom Server-to-Server Oauth App ### Setup guide +Please read [How to generate your Server-to-Server OAuth app ](https://developers.zoom.us/docs/internal-apps/s2s-oauth/). + +:::info + +JWT Tokens are deprecated, only Server-to-Server works now. [link to Zoom](https://developers.zoom.us/docs/internal-apps/jwt-faq/) + +::: -Please read [How to generate your JWT Token](https://marketplace.zoom.us/docs/guides/build/jwt-app). ## Changelog | Version | Date | Pull Request | Subject | -| :------ | :--------- | :------------------------------------------------------- | :--------------------------------------------------------------------- | +|:--------|:-----------|:---------------------------------------------------------| :--------------------------------------------------------------------- | +| 1.0.0 | 2023-7-28 | [25308](https://github.com/airbytehq/airbyte/pull/25308) | Replace JWT Auth methods with server-to-server Oauth | | 0.1.1 | 2022-11-30 | [19939](https://github.com/airbytehq/airbyte/pull/19939) | Upgrade CDK version to fix bugs with SubStreamSlicer | | 0.1.0 | 2022-10-25 | [18179](https://github.com/airbytehq/airbyte/pull/18179) | Initial Release |