From ea37c40d9a5766cda0ed468dd7490d7317c670bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Luna?= Date: Wed, 27 Nov 2024 01:58:44 -0300 Subject: [PATCH] feat: Add OAuth2 authentication support This commit introduces support for OAuth2 authentication in the tap-jira Singer tap. Key changes include: - Added a new `JiraOAuth2Authenticator` class in `authenticators.py` to handle the OAuth2 authentication flow. This class manages access tokens, refresh tokens, and token expiration. - Updated the `JiraStream` class in `client.py` to use a new `JiraAuthenticator` factory class. This allows the tap to seamlessly switch between basic and OAuth2 authentication based on the provided configuration. - Added new OAuth2 related configuration options to the `TapJira` class in `tap.py`, including `client_id`, `client_secret`, `access_token`, and `refresh_token`. The `config_jsonschema` was updated to reflect these new optional properties and the new `auth_type` property. - Added new unit tests in `tests/test_authenticators.py` to verify the behavior of the new `JiraOAuth2Authenticator` class, including testing token refresh and error handling. - Updated the existing unit tests in `tests/test_core.py` to accommodate the new authentication options and to ensure the tap's core functionality remains intact. This addition provides users with a more secure and flexible authentication option when using the tap-jira. OAuth2 authentication is widely used and recommended by the Jira API for server-to-server integrations. The new code follows the existing design patterns and best practices used throughout the tap. Exception handling and error messages have been included to provide clear feedback in case of authentication issues. Closes #94 --- tap_jira/authenticators.py | 164 +++++++++++++++++++++++++++++++++++ tap_jira/client.py | 11 ++- tap_jira/tap.py | 66 +++++++++++--- tests/test_authenticators.py | 55 ++++++++++++ tests/test_core.py | 17 ++++ 5 files changed, 293 insertions(+), 20 deletions(-) create mode 100644 tap_jira/authenticators.py create mode 100644 tests/test_authenticators.py diff --git a/tap_jira/authenticators.py b/tap_jira/authenticators.py new file mode 100644 index 0000000..ccab347 --- /dev/null +++ b/tap_jira/authenticators.py @@ -0,0 +1,164 @@ +#tap_jira/authenticators.py +"""Authentication handling for tap-jira.""" + +from datetime import datetime, timedelta, timezone +from typing import Optional + +import requests +from singer_sdk.authenticators import APIAuthenticatorBase, BasicAuthenticator + + +class JiraAuthError(Exception): + """Custom exception for Jira authentication errors.""" + pass + + +class JiraOAuthError(JiraAuthError): + """Custom exception for OAuth-related errors.""" + pass + + +class JiraBasicAuthenticator(BasicAuthenticator): + """Handles Basic authentication for Jira using API tokens.""" + + def __init__(self, stream) -> None: + """Initialize authenticator. + + Args: + stream: The stream instance requiring authentication. + + Raises: + JiraAuthError: If email or api_token is missing. + """ + email = stream.config.get("email") + api_token = stream.config.get("api_token") + + if not email or not api_token: + raise JiraAuthError( + "Both email and api_token are required for basic authentication" + ) + + super().__init__( + stream=stream, + username=email, + password=api_token, + ) + +class JiraOAuth2Authenticator(APIAuthenticatorBase): + """Handles OAuth 2.0 authentication for Jira.""" + + def __init__( + self, + stream, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + ) -> None: + """Initialize authenticator. + + Args: + stream: The stream instance requiring authentication. + client_id: OAuth2 client ID. + client_secret: OAuth2 client secret. + access_token: OAuth2 access token. + refresh_token: OAuth2 refresh token. + """ + super().__init__(stream=stream) + self.client_id = client_id or stream.config.get("client_id") + self.client_secret = client_secret or stream.config.get("client_secret") + self._access_token = access_token or stream.config.get("access_token") + self._refresh_token = refresh_token or stream.config.get("refresh_token") + self._expires_at: Optional[datetime] = None + + if not self._access_token: + raise JiraOAuthError( + "access_token is required for OAuth authentication" + ) + + def get_auth_header(self) -> dict: + """Return a dictionary of OAuth headers. + + Returns: + Dictionary containing the Authorization header. + """ + return {"Authorization": f"Bearer {self._access_token}"} + + @property + def auth_headers(self) -> dict: + """Return headers with valid OAuth token. + + Returns: + Dictionary containing authentication headers. + """ + if self.is_token_expired(): + self.refresh_access_token() + return self.get_auth_header() + + def is_token_expired(self) -> bool: + """Check if token is expired. + + Returns: + True if token is expired, False otherwise. + """ + if not self._expires_at: + return False + return datetime.now(timezone.utc) >= self._expires_at + + def refresh_access_token(self) -> None: + """Refresh OAuth access token. + + Raises: + JiraOAuthError: If required OAuth credentials are missing or refresh fails. + """ + if not (self.client_id and self.client_secret and self._refresh_token): + raise JiraOAuthError( + "Missing OAuth2 credentials for refresh. Ensure client_id, client_secret, " + "and refresh_token are provided." + ) + + try: + response = requests.post( + "https://auth.atlassian.com/oauth/token", + json={ + "grant_type": "refresh_token", + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": self._refresh_token, + }, + ) + response.raise_for_status() + data = response.json() + except requests.RequestException as e: + raise JiraOAuthError(f"Failed to refresh access token: {str(e)}") from e + + self._access_token = data["access_token"] + if "refresh_token" in data: + self._refresh_token = data["refresh_token"] + self._expires_at = datetime.now(timezone.utc) + timedelta(seconds=data["expires_in"]) + + +class JiraAuthenticator: + """Factory class for creating appropriate Jira authenticator.""" + + @staticmethod + def create_for_stream(stream) -> APIAuthenticatorBase: + """Create and return an authenticator instance for the stream. + + Args: + stream: The stream instance requiring authentication. + + Returns: + An authenticator instance appropriate for the stream's configuration. + + Raises: + JiraAuthError: If authentication configuration is invalid. + """ + auth_type = stream.config.get("auth_type", "basic") + + if auth_type == "oauth2": + return JiraOAuth2Authenticator(stream=stream) + elif auth_type == "basic": + return JiraBasicAuthenticator(stream=stream) + else: + raise JiraAuthError(f"Unsupported authentication type: {auth_type}") \ No newline at end of file diff --git a/tap_jira/client.py b/tap_jira/client.py index 1fbbd2e..5850663 100644 --- a/tap_jira/client.py +++ b/tap_jira/client.py @@ -1,3 +1,4 @@ +#tap_jira/client.py """REST client handling, including tap-jiraStream base class.""" from __future__ import annotations @@ -6,8 +7,10 @@ from typing import Any, Callable import requests -from singer_sdk.authenticators import BasicAuthenticator from singer_sdk.streams import RESTStream +from tap_jira.authenticators import JiraAuthenticator + + _Auth = Callable[[requests.PreparedRequest], requests.PreparedRequest] SCHEMAS_DIR = Path(__file__).parent / Path("./schemas") @@ -41,11 +44,7 @@ def authenticator(self) -> _Auth: Returns: An authenticator instance. """ - return BasicAuthenticator.create_for_stream( - self, - password=self.config["api_token"], - username=self.config["email"], - ) + return JiraAuthenticator.create_for_stream(self) @property def http_headers(self) -> dict: diff --git a/tap_jira/tap.py b/tap_jira/tap.py index 6461707..40e7645 100644 --- a/tap_jira/tap.py +++ b/tap_jira/tap.py @@ -1,3 +1,4 @@ +#tap_jira/tap.py """tap-jira tap class.""" from __future__ import annotations @@ -15,33 +16,70 @@ class TapJira(Tap): config_jsonschema = th.PropertiesList( th.Property( - "start_date", - th.DateTimeType, - description="Earliest record date to sync", + "auth_type", + th.StringType, + description="Authentication type ('basic' or 'oauth2')", + default="basic" ), + # Basic Auth Settings th.Property( - "end_date", - th.DateTimeType, - description="Latest record date to sync", + "api_token", + th.StringType, + description="Jira API Token (required for basic auth)", + secret=True, + required=False, ), th.Property( - "domain", + "email", th.StringType, - description="The Domain for your Jira account, e.g. meltano.atlassian.net", - required=True, + description="The user email for your Jira account (required for basic auth)", + required=False, ), + # OAuth2 Settings th.Property( - "api_token", + "client_id", th.StringType, - description="Jira API Token.", - required=True, + description="OAuth2 Client ID (required for OAuth)", + required=False, ), th.Property( - "email", + "client_secret", + th.StringType, + description="OAuth2 Client Secret (required for OAuth)", + secret=True, + required=False, + ), + th.Property( + "access_token", th.StringType, - description="The user email for your Jira account.", + description="OAuth2 Access Token", + secret=True, + required=False, + ), + th.Property( + "refresh_token", + th.StringType, + description="OAuth2 Refresh Token", + secret=True, + required=False, + ), + # Common Settings + th.Property( + "domain", + th.StringType, + description="The Domain for your Jira account, e.g. meltano.atlassian.net", required=True, ), + th.Property( + "start_date", + th.DateTimeType, + description="Earliest record date to sync", + ), + th.Property( + "end_date", + th.DateTimeType, + description="Latest record date to sync", + ), th.Property( "page_size", th.ObjectType( diff --git a/tests/test_authenticators.py b/tests/test_authenticators.py new file mode 100644 index 0000000..054f43b --- /dev/null +++ b/tests/test_authenticators.py @@ -0,0 +1,55 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, patch + +import pytest +from tap_jira.authenticators import JiraOAuth2Authenticator, JiraOAuthError + +class TestJiraOAuth2Authenticator: + def test_init_with_valid_credentials(self): + stream = Mock( + config={ + "client_id": "client_id", + "client_secret": "client_secret", + "access_token": "access_token", + "refresh_token": "refresh_token", + } + ) + authenticator = JiraOAuth2Authenticator(stream) + assert authenticator.client_id == "client_id" + assert authenticator.client_secret == "client_secret" + assert authenticator._access_token == "access_token" + assert authenticator._refresh_token == "refresh_token" + + def test_init_with_missing_access_token(self): + stream = Mock(config={"access_token": None}) + with pytest.raises(JiraOAuthError): + JiraOAuth2Authenticator(stream) + + @patch("tap_jira.authenticators.requests.post") + def test_refresh_access_token(self, mock_post): + mock_post.return_value.json.return_value = { + "access_token": "new_access_token", + "refresh_token": "new_refresh_token", + "expires_in": 3600, + } + stream = Mock( + config={ + "client_id": "client_id", + "client_secret": "client_secret", + "refresh_token": "refresh_token", + } + ) + authenticator = JiraOAuth2Authenticator( + stream, access_token="existing_access_token" + ) + authenticator.refresh_access_token() + assert authenticator._access_token == "new_access_token" + assert authenticator._refresh_token == "new_refresh_token" + assert authenticator._expires_at > datetime.now(timezone.utc) + + @patch("tap_jira.authenticators.requests.post") + def test_refresh_access_token_with_missing_credentials(self, mock_post): + stream = Mock(config={"client_id": None, "client_secret": None, "refresh_token": None}) + authenticator = JiraOAuth2Authenticator(stream, access_token="existing_access_token") + with pytest.raises(JiraOAuthError): + authenticator.refresh_access_token() \ No newline at end of file diff --git a/tests/test_core.py b/tests/test_core.py index c1ec7e5..27c2092 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,11 +2,15 @@ import datetime import os +from unittest.mock import Mock, patch from singer_sdk.testing import SuiteConfig, get_tap_test_class from tap_jira.tap import TapJira +from tap_jira.client import JiraStream +from tap_jira.authenticators import JiraBasicAuthenticator, JiraOAuth2Authenticator + SAMPLE_CONFIG = { "start_date": "2023-01-01T00:00:00Z", "domain": os.environ.get("TAP_JIRA_DOMAIN"), @@ -26,3 +30,16 @@ config=SAMPLE_CONFIG, suite_config=SuiteConfig(), ) + +class TestJiraStream: + @patch("tap_jira.client.JiraAuthenticator.create_for_stream") + def test_basic_authenticator(self, mock_create_authenticator): + mock_create_authenticator.return_value = JiraBasicAuthenticator(MagicMock()) + stream = JiraStream(MagicMock()) + assert isinstance(stream.authenticator, JiraBasicAuthenticator) + + @patch("tap_jira.client.JiraAuthenticator.create_for_stream") + def test_oauth2_authenticator(self, mock_create_authenticator): + mock_create_authenticator.return_value = JiraOAuth2Authenticator(MagicMock()) + stream = JiraStream(MagicMock(config={"auth_type": "oauth2"})) + assert isinstance(stream.authenticator, JiraOAuth2Authenticator) \ No newline at end of file