-
Notifications
You must be signed in to change notification settings - Fork 18
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
feat: Add OAuth2 authentication support #95
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to represent the desired auth flow using |
||
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( | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't most of this redundant if the class extended
singer_sdk.authenticators.OAuthAuthenticator
(or instantiated directly increate_for_stream
)?