From 8b6357fceae50e8edbb8a75021d8d789e09455ce Mon Sep 17 00:00:00 2001 From: Fabian Franz Steiner <75947402+fasteiner@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:49:13 +0200 Subject: [PATCH 1/5] Add OAuth client credentials support --- CHANGELOG.md | 4 + README.md | 24 ++++++ src/xurrent/core.py | 74 +++++++++++++++- tests/unit_tests/test_core_oauth.py | 125 ++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_core_oauth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cbeb252..8365da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Core: support OAuth client credentials authentication via `client_id` and `client_secret` in `XurrentApiHelper` while maintaining API key compatibility. + ### Changed #### `.github/workflows/release.yml` diff --git a/README.md b/README.md index cfe8fd6..8af901f 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,30 @@ This module is used to interact with the Xurrent API. It provides a set of class ``` +#### Using OAuth client credentials + +You can let the helper automatically request and refresh bearer tokens by providing the OAuth +`client_id` and `client_secret` that were issued to your application. The original API key flow +continues to work unchanged, but only one authentication method may be used per helper instance. + +```python + from xurrent.core import XurrentApiHelper + + baseUrl = "https://api.xurrent.qa/v1" + account = "account-name" + client_id = "your-client-id" + client_secret = "your-client-secret" + + x_api_helper = XurrentApiHelper( + baseUrl, + api_account=account, + client_id=client_id, + client_secret=client_secret, + ) + + response = x_api_helper.api_call("/requests", "GET") +``` + #### Configuration Items ```python diff --git a/src/xurrent/core.py b/src/xurrent/core.py index 9ad836f..5d2d3fe 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -7,6 +7,8 @@ import json import re import base64 +from logging import Logger +from typing import Optional, List class LogLevel(Enum): DEBUG = logging.DEBUG @@ -46,29 +48,95 @@ class XurrentApiHelper: api_user: Person # Forward declaration with a string api_user_teams: List[Team] # Forward declaration with a string - def __init__(self, base_url, api_key, api_account,resolve_user=True, logger: Logger=None): + def __init__( + self, + base_url, + api_key=None, + api_account=None, + resolve_user=True, + logger: Logger=None, + client_id: Optional[str]=None, + client_secret: Optional[str]=None + ): """ Initialize the Xurrent API helper. :param base_url: Base URL of the Xurrent API :param api_key: API key to authenticate with :param api_account: Account name to use + :param client_id: OAuth client ID to use when fetching an access token + :param client_secret: OAuth client secret to use when fetching an access token :param resolve_user: Resolve the API user and their teams (default: True) :param logger: Logger to use (optional), otherwise a new logger is created """ self.base_url = base_url - self.api_key = api_key self.api_account = api_account + self._client_id = client_id + self._client_secret = client_secret + self._token_expires_at: Optional[float] = None + + if bool(api_key) == bool(client_id and client_secret): + raise ValueError('Provide either api_key or both client_id and client_secret, but not both.') + if not self.api_account: + raise ValueError('api_account must be provided.') + if logger: self.logger = logger else: self.logger = self.create_logger(False) + if client_id or client_secret: + if not (client_id and client_secret): + raise ValueError('Both client_id and client_secret are required for OAuth authentication.') + self.api_key = None + self._obtain_access_token() + else: + self.api_key = api_key if resolve_user: # Import Person lazily from .people import Person self.api_user = Person.get_me(self) self.api_user_teams = self.api_user.get_teams() + def _ensure_access_token(self): + """Ensure that a valid access token is available.""" + if not self._client_id: + return + + needs_refresh = self.api_key is None + if self._token_expires_at is not None: + needs_refresh = needs_refresh or time.time() >= self._token_expires_at + + if needs_refresh: + self._obtain_access_token() + + def _obtain_access_token(self): + """Fetch a new OAuth access token using the client credentials grant.""" + token_url = 'https://oauth.xurrent.com/token' + payload = { + 'client_id': self._client_id, + 'client_secret': self._client_secret, + 'grant_type': 'client_credentials' + } + + try: + response = requests.post(token_url, data=payload) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + self.logger.error(f'Failed to obtain OAuth access token: {exc}') + raise + + data = response.json() + access_token = data.get('access_token') + if not access_token: + self.logger.error('OAuth token response did not contain an access_token.') + raise ValueError('OAuth token response did not contain an access_token.') + + expires_in = data.get('expires_in', 3600) + buffer_seconds = 60 + self._token_expires_at = time.time() + max(expires_in - buffer_seconds, 0) + self.api_key = access_token + self.logger.debug('Obtained new OAuth access token.') + def __append_per_page(self, uri, per_page=100): """ Append the 'per_page' parameter to the URI if not already present. @@ -143,6 +211,8 @@ def api_call(self, uri: str, method='GET', data=None, per_page=100): if not uri.startswith(self.base_url): uri = f'{self.base_url}{uri}' + self._ensure_access_token() + headers = { 'Authorization': f'Bearer {self.api_key}', 'x-xurrent-account': self.api_account diff --git a/tests/unit_tests/test_core_oauth.py b/tests/unit_tests/test_core_oauth.py new file mode 100644 index 0000000..6a7548e --- /dev/null +++ b/tests/unit_tests/test_core_oauth.py @@ -0,0 +1,125 @@ +import os +import sys +from unittest.mock import MagicMock + +import pytest +import requests + +# Add the `../src` directory to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper + + +@pytest.fixture +def mock_token_response(): + def _factory(access_token="token", expires_in=3600): + response = MagicMock() + response.json.return_value = {"access_token": access_token, "expires_in": expires_in} + response.raise_for_status = MagicMock() + return response + + return _factory + + +def test_init_requires_authentication_method(): + with pytest.raises(ValueError): + XurrentApiHelper("https://api.example.com", api_account="account", resolve_user=False) + + +def test_init_with_both_auth_methods_raises(): + with pytest.raises(ValueError): + XurrentApiHelper( + "https://api.example.com", + api_key="token", + api_account="account", + resolve_user=False, + client_id="cid", + client_secret="secret", + ) + + +def test_client_credentials_fetches_token(monkeypatch, mock_token_response): + post_calls = [] + + def fake_post(url, data): + post_calls.append((url, data)) + return mock_token_response("oauth-token") + + api_responses = [] + + def fake_request(method, url, headers=None, json=None): + api_responses.append({"method": method, "url": url, "headers": headers, "json": json}) + response = MagicMock() + response.status_code = 200 + response.ok = True + response.json.return_value = {"result": "ok"} + response.headers = {} + return response + + monkeypatch.setattr(requests, "post", fake_post) + monkeypatch.setattr(requests, "request", fake_request) + + helper = XurrentApiHelper( + "https://api.example.com", + api_account="account", + resolve_user=False, + client_id="cid", + client_secret="secret", + ) + + result = helper.api_call("/resource") + + assert result == {"result": "ok"} + assert post_calls == [ + ( + "https://oauth.xurrent.com/token", + { + "client_id": "cid", + "client_secret": "secret", + "grant_type": "client_credentials", + }, + ) + ] + assert api_responses[0]["headers"]["Authorization"] == "Bearer oauth-token" + + +def test_client_credentials_refreshes_token(monkeypatch): + token_payloads = [ + {"access_token": "token-1", "expires_in": 0}, + {"access_token": "token-2", "expires_in": 3600}, + ] + post_count = 0 + + def fake_post(url, data): + nonlocal post_count + response = MagicMock() + payload = token_payloads[post_count] + response.json.return_value = payload + response.raise_for_status = MagicMock() + post_count += 1 + return response + + def fake_request(method, url, headers=None, json=None): + response = MagicMock() + response.status_code = 200 + response.ok = True + response.json.return_value = {"result": "ok"} + response.headers = {} + return response + + monkeypatch.setattr(requests, "post", fake_post) + monkeypatch.setattr(requests, "request", fake_request) + + helper = XurrentApiHelper( + "https://api.example.com", + api_account="account", + resolve_user=False, + client_id="cid", + client_secret="secret", + ) + + helper.api_call("/resource-1") + helper.api_call("/resource-2") + + assert post_count == 2 From 24297b77572dd96210b0b5e53f81ff471a1550ae Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Steiner Bsc." Date: Thu, 9 Oct 2025 15:46:05 +0200 Subject: [PATCH 2/5] Enhance OAuth support by dynamically deriving token endpoint TLD and auto-refreshing tokens on 401 errors; update README and CHANGELOG accordingly. --- .vscode/settings.json | 3 +- CHANGELOG.md | 2 + README.md | 4 +- src/xurrent/core.py | 126 +++++++++++++++++++++--------------------- 4 files changed, 68 insertions(+), 67 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1265994..828249e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "tests" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "python.analysis.typeCheckingMode": "basic" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8365da2..10197a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Core: support OAuth client credentials authentication via `client_id` and `client_secret` in `XurrentApiHelper` while maintaining API key compatibility. +- Core: OAuth token endpoint TLD is now dynamically derived from the API base URL, ensuring the same top-level domain is used for both API and OAuth. +- Core: When using OAuth, if a 401 Unauthorized error is received, the token is automatically refreshed and the API call is retried once. ### Changed diff --git a/README.md b/README.md index 8af901f..e759543 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ This module is used to interact with the Xurrent API. It provides a set of class # Plain API Call uri = "/requests?subject=Example Subject" - connection_object.api_call(uri, 'GET') + x_api_helper.api_call(uri, 'GET') # Convert node ID - helper.decode_api_id('ZmFiaWFuc3RlaW5lci4yNDEyMTAxMDE0MTJANG1lLWRlbW8uY29tL1JlcS83MDU3NTU') # fabiansteiner.241210101412@4me-demo.com/Req/705755 + x_api_helper.decode_api_id('ZmFiaWFuc3RlaW5lci4yNDEyMTAxMDE0MTJANG1lLWRlbW8uY29tL1JlcS83MDU3NTU') # fabiansteiner.241210101412@4me-demo.com/Req/705755 # this can be used to derive the ID from the nodeID ``` diff --git a/src/xurrent/core.py b/src/xurrent/core.py index 5d2d3fe..a8b517b 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -111,7 +111,15 @@ def _ensure_access_token(self): def _obtain_access_token(self): """Fetch a new OAuth access token using the client credentials grant.""" - token_url = 'https://oauth.xurrent.com/token' + # Dynamically determine the TLD from the base_url + import urllib.parse + parsed = urllib.parse.urlparse(self.base_url) + # Extract the netloc (e.g. api.xurrent.com) and replace the subdomain with 'oauth' + netloc_parts = parsed.netloc.split('.') + if len(netloc_parts) < 2: + raise ValueError('Invalid base_url for extracting TLD') + tld = '.'.join(netloc_parts[-2:]) + token_url = f'https://oauth.{tld}/token' payload = { 'client_id': self._client_id, 'client_secret': self._client_secret, @@ -201,6 +209,7 @@ def set_log_level(self, level: LogLevel): def api_call(self, uri: str, method='GET', data=None, per_page=100): """ Make a call to the Xurrent API with support for rate limiting and pagination. + Automatically handles 401 responses by refreshing the OAuth token if client_id and client_secret are provided. :param uri: URI to call :param method: HTTP method to use :param data: Data to send with the request (optional) @@ -211,70 +220,59 @@ def api_call(self, uri: str, method='GET', data=None, per_page=100): if not uri.startswith(self.base_url): uri = f'{self.base_url}{uri}' - self._ensure_access_token() - - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'x-xurrent-account': self.api_account - } - - aggregated_data = [] - next_page_url = uri - - while next_page_url: - try: - # Append pagination parameters for GET requests - if method == 'GET': - # if contains ? or does not end with /, append per_page - next_page_url = self.__append_per_page(next_page_url, per_page) - - # Log the request - self.logger.debug(f'{method} {next_page_url} {data if method != "GET" else ""}') - - # Make the HTTP request - response = requests.request(method, next_page_url, headers=headers, json=data) - - if response.status_code == 204: - return None - - # Handle rate limiting (429 status code) - if response.status_code == 429: - retry_after = int(response.headers.get('Retry-After', 1)) # Default to 1 second if not provided - self.logger.warning(f'Rate limit reached. Retrying after {retry_after} seconds...') - time.sleep(retry_after) - continue - - # Check for other non-success status codes - if not response.ok: - self.logger.error(f'Error in request: {response.status_code} - {response.text}') - response.raise_for_status() - - # Process response - response_data = response.json() - - # For GET requests, handle pagination - if method == 'GET' and isinstance(response_data, list): - aggregated_data.extend(response_data) - - # Parse the 'Link' header to find the 'next' page URL - link_header = response.headers.get('Link') - if link_header: - links = {rel.strip(): url.strip('<>') for url, rel in - (link.split(';') for link in link_header.split(','))} - next_page_url = links.get('rel="next"') - if next_page_url: - next_page_url = next_page_url.replace('<', '').replace('>', '') + def do_request(): + self._ensure_access_token() + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'x-xurrent-account': self.api_account + } + aggregated_data = [] + next_page_url = uri + while next_page_url: + try: + # Append pagination parameters for GET requests + if method == 'GET': + next_page_url = self.__append_per_page(next_page_url, per_page) + self.logger.debug(f'{method} {next_page_url} {data if method != "GET" else ""}') + response = requests.request(method, next_page_url, headers=headers, json=data) + if response.status_code == 204: + return None + if response.status_code == 429: + retry_after = int(response.headers.get('Retry-After', 1)) + self.logger.warning(f'Rate limit reached. Retrying after {retry_after} seconds...') + time.sleep(retry_after) + continue + if response.status_code == 401 and self._client_id: + # Signal to outer logic to refresh token and retry + return '401-refresh' + if not response.ok: + self.logger.error(f'Error in request: {response.status_code} - {response.text}') + response.raise_for_status() + response_data = response.json() + if method == 'GET' and isinstance(response_data, list): + aggregated_data.extend(response_data) + link_header = response.headers.get('Link') + if link_header: + links = {rel.strip(): url.strip('<>') for url, rel in + (link.split(';') for link in link_header.split(','))} + next_page_url = links.get('rel="next"') + if next_page_url: + next_page_url = next_page_url.replace('<', '').replace('>', '') + else: + next_page_url = None else: - next_page_url = None - else: - return response_data # Return for non-GET requests - - except requests.exceptions.RequestException as e: - self.logger.error(f'HTTP request failed: {e}') - raise - - # Return aggregated results for paginated GET - return aggregated_data + return response_data + except requests.exceptions.RequestException as e: + self.logger.error(f'HTTP request failed: {e}') + raise + return aggregated_data + + result = do_request() + if result == '401-refresh': + self.logger.info('401 Unauthorized received, refreshing OAuth token and retrying request...') + self._obtain_access_token() + result = do_request() + return result def custom_fields_to_object(self, custom_fields): """ From 11270de86e13459917eb03b6794483b80cd5ffb8 Mon Sep 17 00:00:00 2001 From: fasteiner <75947402+fasteiner@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:10:24 +0000 Subject: [PATCH 3/5] Update pyproject.toml for release 0.11.0-preview.4 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6d87f2f..54183e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xurrent" -version = "0.10.0" +version = "0.11.0-preview.4" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] @@ -18,7 +18,7 @@ Homepage = "https://github.com/fasteiner/xurrent-python" Issues = "https://github.com/fasteiner/xurrent-python/issues" [tool.poetry] name = "xurrent" -version = "0.10.0" +version = "0.11.0-preview.4" description = "A python module to interact with the Xurrent API." authors = ["Ing. Fabian Franz Steiner BSc. "] readme = "README.md" From 0278c6dfe8eaeb4e288a8ff2a512d7de5dad861c Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Steiner Bsc." Date: Thu, 9 Oct 2025 16:33:39 +0200 Subject: [PATCH 4/5] Refine OAuth token endpoint handling by preserving regional subdomains and updating comments for clarity --- CHANGELOG.md | 2 +- src/xurrent/core.py | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 881a1a3..2a49e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Core: support OAuth client credentials authentication via `client_id` and `client_secret` in `XurrentApiHelper` while maintaining API key compatibility. -- Core: OAuth token endpoint TLD is now dynamically derived from the API base URL, ensuring the same top-level domain is used for both API and OAuth. +- Core: The OAuth token endpoint now dynamically determines the domain from `base_url`, preserving any regional subdomains to ensure consistency between API and OAuth endpoints. - Core: When using OAuth, if a 401 Unauthorized error is received, the token is automatically refreshed and the API call is retried once. If authentication still fails after token refresh, an explicit HTTPError is raised. ## [0.10.0] - 2025-08-16 diff --git a/src/xurrent/core.py b/src/xurrent/core.py index f9cc6a9..1fcbcc6 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -117,15 +117,24 @@ def _ensure_access_token(self): def _obtain_access_token(self): """Fetch a new OAuth access token using the client credentials grant.""" - # Dynamically determine the TLD from the base_url + # Dynamically determine the domain from the base_url and preserve regional subdomains import urllib.parse parsed = urllib.parse.urlparse(self.base_url) - # Extract the netloc (e.g. api.xurrent.com) and replace the subdomain with 'oauth' + # Extract the netloc (e.g. api.xurrent.com, api.au.xurrent.com) and replace 'api' with 'oauth' netloc_parts = parsed.netloc.split('.') if len(netloc_parts) < 2: - raise ValueError('Invalid base_url for extracting TLD') - tld = '.'.join(netloc_parts[-2:]) - token_url = f'https://oauth.{tld}/token' + raise ValueError('Invalid base_url for extracting domain') + + # Replace the first part (assumed to be 'api') with 'oauth' + if netloc_parts[0] == 'api': + netloc_parts[0] = 'oauth' + else: + self.logger.warning(f"Expected first domain part to be 'api', got '{netloc_parts[0]}'. Proceeding anyway.") + netloc_parts[0] = 'oauth' + + # Reconstruct the domain preserving all parts including regional subdomains + oauth_domain = '.'.join(netloc_parts) + token_url = f'https://{oauth_domain}/token' payload = { 'client_id': self._client_id, 'client_secret': self._client_secret, From 377c715db2e61e074f501c098e194569d3454a44 Mon Sep 17 00:00:00 2001 From: fasteiner <75947402+fasteiner@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:34:13 +0000 Subject: [PATCH 5/5] Update pyproject.toml for release 0.11.0-preview.10 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 54183e3..97fa634 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xurrent" -version = "0.11.0-preview.4" +version = "0.11.0-preview.10" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] @@ -18,7 +18,7 @@ Homepage = "https://github.com/fasteiner/xurrent-python" Issues = "https://github.com/fasteiner/xurrent-python/issues" [tool.poetry] name = "xurrent" -version = "0.11.0-preview.4" +version = "0.11.0-preview.10" description = "A python module to interact with the Xurrent API." authors = ["Ing. Fabian Franz Steiner BSc. "] readme = "README.md"