Skip to content
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
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions tap_jira/authenticators.py
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):
Copy link
Contributor

@ReubenFrankel ReubenFrankel Nov 27, 2024

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 in create_for_stream)?

"""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}")
11 changes: 5 additions & 6 deletions tap_jira/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#tap_jira/client.py
"""REST client handling, including tap-jiraStream base class."""

from __future__ import annotations
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
66 changes: 52 additions & 14 deletions tap_jira/tap.py
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
Expand All @@ -15,33 +16,70 @@ class TapJira(Tap):

config_jsonschema = th.PropertiesList(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to represent the desired auth flow using singer_sdk.typing.DiscriminatedUnion as it was previously?

https://github.com/MeltanoLabs/tap-jira/pull/86/files#diff-9a8a5da31511510c569573541d7460963e518208e1f71162cf6847378a12dfb4L33-L48

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(
Expand Down
55 changes: 55 additions & 0 deletions tests/test_authenticators.py
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()
17 changes: 17 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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)