diff --git a/splitio/api/auth.py b/splitio/api/auth.py new file mode 100644 index 00000000..97a5d1e1 --- /dev/null +++ b/splitio/api/auth.py @@ -0,0 +1,54 @@ +"""Auth API module.""" + +import logging +import json + +from future.utils import raise_from + +from splitio.api import APIException, headers_from_metadata +from splitio.api.client import HttpClientException +from splitio.models.token import from_raw + + +class AuthAPI(object): #pylint: disable=too-few-public-methods + """Class that uses an httpClient to communicate with the SDK Auth Service API.""" + + def __init__(self, client, apikey, sdk_metadata): + """ + Class constructor. + + :param client: HTTP Client responsble for issuing calls to the backend. + :type client: HttpClient + :param apikey: User apikey token. + :type apikey: string + :param sdk_metadata: SDK version & machine name & IP. + :type sdk_metadata: splitio.client.util.SdkMetadata + """ + self._logger = logging.getLogger(self.__class__.__name__) + self._client = client + self._apikey = apikey + self._metadata = headers_from_metadata(sdk_metadata) + + def authenticate(self): + """ + Performs authentication. + + :return: Json representation of an authentication. + :rtype: dict + """ + try: + response = self._client.get( + 'auth', + '/auth', + self._apikey, + extra_headers=self._metadata + ) + if 200 <= response.status_code < 300: + payload = json.loads(response.body) + return from_raw(payload) + else: + raise APIException(response.body, response.status_code) + except HttpClientException as exc: + self._logger.error('Exception raised while authenticating') + self._logger.debug('Exception information: ', exc_info=True) + raise_from(APIException('Could not perform authentication.'), exc) diff --git a/splitio/api/client.py b/splitio/api/client.py index fd3bb9b8..86945a27 100644 --- a/splitio/api/client.py +++ b/splitio/api/client.py @@ -27,8 +27,9 @@ class HttpClient(object): SDK_URL = 'https://sdk.split.io/api' EVENTS_URL = 'https://events.split.io/api' + AUTH_URL = 'https://auth.split.io/api' - def __init__(self, timeout=None, sdk_url=None, events_url=None): + def __init__(self, timeout=None, sdk_url=None, events_url=None, auth_url=None): """ Class constructor. @@ -38,11 +39,14 @@ def __init__(self, timeout=None, sdk_url=None, events_url=None): :type sdk_url: str :param events_url: Optional alternative events URL. :type events_url: str + :param auth_url: Optional alternative auth URL. + :type auth_url: str """ self._timeout = timeout / 1000 if timeout else None # Convert ms to seconds. self._urls = { 'sdk': sdk_url if sdk_url is not None else self.SDK_URL, 'events': events_url if events_url is not None else self.EVENTS_URL, + 'auth': auth_url if auth_url is not None else self.AUTH_URL, } def _build_url(self, server, path): @@ -76,7 +80,7 @@ def get(self, server, path, apikey, query=None, extra_headers=None): #pylint: d """ Issue a get request. - :param server: Whether the request is for SDK server or Events server. + :param server: Whether the request is for SDK server, Events server or Auth server. :typee server: str :param path: path to append to the host url. :type path: str diff --git a/splitio/models/token.py b/splitio/models/token.py new file mode 100644 index 00000000..3c050d57 --- /dev/null +++ b/splitio/models/token.py @@ -0,0 +1,89 @@ +"""Token module""" + +import base64 +import json + +class Token(object): + """Token object class.""" + + def __init__(self, push_enabled, token, channels, exp, iat): + """ + Class constructor. + + :param push_enabled: flag push enabled. + :type push_enabled: bool + + :param token: Token from auth. + :type token: str + + :param channels: Channels parsed from token. + :type channels: str + + :param exp: exp parsed from token. + :type exp: int + + :param iat: iat parsed from token. + :type iat: int + """ + self._push_enabled = push_enabled + self._token = token + self._channels = channels + self._exp = exp + self._iat = iat + + @property + def push_enabled(self): + """Return push_enabled""" + return self._push_enabled + + @property + def token(self): + """Return token""" + return self._token + + @property + def channels(self): + """Return channels""" + return self._channels + + @property + def exp(self): + """Return exp""" + return self._exp + + @property + def iat(self): + """Return iat""" + return self._iat + + +def decode_token(raw_token): + """Decode token""" + if not 'pushEnabled' in raw_token or not 'token' in raw_token: + return None, None, None + + token = raw_token['token'] + push_enabled = raw_token['pushEnabled'] + if not push_enabled or len(token.strip()) == 0: + return None, None, None + + token_parts = token.split('.') + if len(token_parts) < 2: + return None, None, None + + to_decode = token_parts[1] + decoded_payload = base64.b64decode(to_decode + '='*(-len(to_decode) % 4)) + return push_enabled, token, json.loads(decoded_payload) + +def from_raw(raw_token): + """ + Parse a new token from a raw token response. + + :param raw_token: Token parsed from auth response. + :type raw_token: dict + + :return: New token model object + :rtype: splitio.models.token.Token + """ + push_enabled, token, decoded_token = decode_token(raw_token) + return None if push_enabled is None else Token(push_enabled, token, json.loads(decoded_token['x-ably-capability']), decoded_token['exp'], decoded_token['iat']) diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 00000000..5f145a21 --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,47 @@ +"""Split API tests module.""" + +import pytest +from splitio.api import auth, client, APIException +from splitio.client.util import get_metadata +from splitio.client.config import DEFAULT_CONFIG +from splitio.version import __version__ + + +class AuthAPITests(object): + """Auth API test cases.""" + + def test_auth(self, mocker): + """Test auth API call.""" + token = "eyJhbGciOiJIUzI1NiIsImtpZCI6IjVZOU05US45QnJtR0EiLCJ0eXAiOiJKV1QifQ.eyJ4LWFibHktY2FwYWJpbGl0eSI6IntcIk56TTJNREk1TXpjMF9NVGd5TlRnMU1UZ3dOZz09X3NlZ21lbnRzXCI6W1wic3Vic2NyaWJlXCJdLFwiTnpNMk1ESTVNemMwX01UZ3lOVGcxTVRnd05nPT1fc3BsaXRzXCI6W1wic3Vic2NyaWJlXCJdLFwiY29udHJvbF9wcmlcIjpbXCJzdWJzY3JpYmVcIixcImNoYW5uZWwtbWV0YWRhdGE6cHVibGlzaGVyc1wiXSxcImNvbnRyb2xfc2VjXCI6W1wic3Vic2NyaWJlXCIsXCJjaGFubmVsLW1ldGFkYXRhOnB1Ymxpc2hlcnNcIl19IiwieC1hYmx5LWNsaWVudElkIjoiY2xpZW50SWQiLCJleHAiOjE2MDIwODgxMjcsImlhdCI6MTYwMjA4NDUyN30.5_MjWonhs6yoFhw44hNJm3H7_YMjXpSW105DwjjppqE" + httpclient = mocker.Mock(spec=client.HttpClient) + payload = '{{"pushEnabled": true, "token": "{token}"}}'.format(token=token) + cfg = DEFAULT_CONFIG.copy() + cfg.update({'IPAddressesEnabled': True, 'machineName': 'some_machine_name', 'machineIp': '123.123.123.123'}) + sdk_metadata = get_metadata(cfg) + httpclient.get.return_value = client.HttpResponse(200, payload) + auth_api = auth.AuthAPI(httpclient, 'some_api_key', sdk_metadata) + response = auth_api.authenticate() + + assert response.push_enabled == True + assert response.token == token + + call_made = httpclient.get.mock_calls[0] + + # validate positional arguments + assert call_made[1] == ('auth', '/auth', 'some_api_key') + + # validate key-value args (headers) + assert call_made[2]['extra_headers'] == { + 'SplitSDKVersion': 'python-%s' % __version__, + 'SplitSDKMachineIP': '123.123.123.123', + 'SplitSDKMachineName': 'some_machine_name' + } + + httpclient.reset_mock() + def raise_exception(*args, **kwargs): + raise client.HttpClientException('some_message') + httpclient.get.side_effect = raise_exception + with pytest.raises(APIException) as exc_info: + response = auth_api.authenticate() + assert exc_info.type == APIException + assert exc_info.value.message == 'some_message' diff --git a/tests/models/test_token.py b/tests/models/test_token.py new file mode 100644 index 00000000..5ab90ed5 --- /dev/null +++ b/tests/models/test_token.py @@ -0,0 +1,45 @@ +"""Split model tests module.""" + +from splitio.models import token +from splitio.models.grammar.condition import Condition + + +class TokenTests(object): + """Token model tests.""" + raw_false = { + 'pushEnabled': False, + 'token': 'eyJhbGciOiJIUzI1NiIsImtpZCI6IjVZOU05US45QnJtR0EiLCJ0eXAiOiJKV1QifQ.eyJ4LWFibHktY2FwYWJpbGl0eSI6IntcIk56TTJNREk1TXpjMF9NVGd5TlRnMU1UZ3dOZz09X3NlZ21lbnRzXCI6W1wic3Vic2NyaWJlXCJdLFwiTnpNMk1ESTVNemMwX01UZ3lOVGcxTVRnd05nPT1fc3BsaXRzXCI6W1wic3Vic2NyaWJlXCJdLFwiY29udHJvbF9wcmlcIjpbXCJzdWJzY3JpYmVcIixcImNoYW5uZWwtbWV0YWRhdGE6cHVibGlzaGVyc1wiXSxcImNvbnRyb2xfc2VjXCI6W1wic3Vic2NyaWJlXCIsXCJjaGFubmVsLW1ldGFkYXRhOnB1Ymxpc2hlcnNcIl19IiwieC1hYmx5LWNsaWVudElkIjoiY2xpZW50SWQiLCJleHAiOjE2MDIwODgxMjcsImlhdCI6MTYwMjA4NDUyN30.5_MjWonhs6yoFhw44hNJm3H7_YMjXpSW105DwjjppqE', + } + + def test_from_raw_false(self): + """Test token model parsing.""" + parsed = token.from_raw(self.raw_false) + assert parsed == None + + raw_empty = { + 'pushEnabled': True, + 'token': '', + } + + def test_from_raw_empty(self): + """Test token model parsing.""" + parsed = token.from_raw(self.raw_empty) + assert parsed == None + + raw_ok = { + 'pushEnabled': True, + 'token': 'eyJhbGciOiJIUzI1NiIsImtpZCI6IjVZOU05US45QnJtR0EiLCJ0eXAiOiJKV1QifQ.eyJ4LWFibHktY2FwYWJpbGl0eSI6IntcIk56TTJNREk1TXpjMF9NVGd5TlRnMU1UZ3dOZz09X3NlZ21lbnRzXCI6W1wic3Vic2NyaWJlXCJdLFwiTnpNMk1ESTVNemMwX01UZ3lOVGcxTVRnd05nPT1fc3BsaXRzXCI6W1wic3Vic2NyaWJlXCJdLFwiY29udHJvbF9wcmlcIjpbXCJzdWJzY3JpYmVcIixcImNoYW5uZWwtbWV0YWRhdGE6cHVibGlzaGVyc1wiXSxcImNvbnRyb2xfc2VjXCI6W1wic3Vic2NyaWJlXCIsXCJjaGFubmVsLW1ldGFkYXRhOnB1Ymxpc2hlcnNcIl19IiwieC1hYmx5LWNsaWVudElkIjoiY2xpZW50SWQiLCJleHAiOjE2MDIwODgxMjcsImlhdCI6MTYwMjA4NDUyN30.5_MjWonhs6yoFhw44hNJm3H7_YMjXpSW105DwjjppqE', + } + + def test_from_raw(self): + """Test token model parsing.""" + parsed = token.from_raw(self.raw_ok) + assert isinstance(parsed, token.Token) + assert parsed.push_enabled == True + assert parsed.iat == 1602084527 + assert parsed.exp == 1602088127 + assert parsed.channels['NzM2MDI5Mzc0_MTgyNTg1MTgwNg==_segments'] == ['subscribe'] + assert parsed.channels['NzM2MDI5Mzc0_MTgyNTg1MTgwNg==_splits'] == ['subscribe'] + assert parsed.channels['control_pri'] == ['subscribe', 'channel-metadata:publishers'] + assert parsed.channels['control_sec'] == ['subscribe', 'channel-metadata:publishers'] +