Skip to content

Commit

Permalink
feat: Organizations Api uptake for twilio-python (#815)
Browse files Browse the repository at this point in the history
* feat: oauth sdk implementation and organization api uptake (#799)
  • Loading branch information
AsabuHere authored Dec 12, 2024
1 parent fb53889 commit 6e78c78
Show file tree
Hide file tree
Showing 25 changed files with 2,899 additions and 16 deletions.
Empty file.
19 changes: 19 additions & 0 deletions twilio/auth_strategy/auth_strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from twilio.auth_strategy.auth_type import AuthType
from abc import abstractmethod


class AuthStrategy(object):
def __init__(self, auth_type: AuthType):
self._auth_type = auth_type

@property
def auth_type(self) -> AuthType:
return self._auth_type

@abstractmethod
def get_auth_string(self) -> str:
"""Return the authentication string."""

@abstractmethod
def requires_authentication(self) -> bool:
"""Return True if authentication is required, else False."""
12 changes: 12 additions & 0 deletions twilio/auth_strategy/auth_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from enum import Enum


class AuthType(Enum):
ORGS_TOKEN = "orgs_stoken"
NO_AUTH = "noauth"
BASIC = "basic"
API_KEY = "api_key"
CLIENT_CREDENTIALS = "client_credentials"

def __str__(self):
return self.value
13 changes: 13 additions & 0 deletions twilio/auth_strategy/no_auth_strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from auth_type import AuthType
from twilio.auth_strategy.auth_strategy import AuthStrategy


class NoAuthStrategy(AuthStrategy):
def __init__(self):
super().__init__(AuthType.NO_AUTH)

def get_auth_string(self) -> str:
return ""

def requires_authentication(self) -> bool:
return False
53 changes: 53 additions & 0 deletions twilio/auth_strategy/token_auth_strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import jwt
import threading
import logging
from datetime import datetime

from twilio.auth_strategy.auth_type import AuthType
from twilio.auth_strategy.auth_strategy import AuthStrategy
from twilio.http.token_manager import TokenManager


class TokenAuthStrategy(AuthStrategy):
def __init__(self, token_manager: TokenManager):
super().__init__(AuthType.ORGS_TOKEN)
self.token_manager = token_manager
self.token = None
self.lock = threading.Lock()
logging.basicConfig(level=logging.INFO)
self.logger = logging.getLogger(__name__)

def get_auth_string(self) -> str:
self.fetch_token()
return f"Bearer {self.token}"

def requires_authentication(self) -> bool:
return True

def fetch_token(self):
if self.token is None or self.token == "" or self.is_token_expired(self.token):
with self.lock:
if (
self.token is None
or self.token == ""
or self.is_token_expired(self.token)
):
self.logger.info("New token fetched for accessing organization API")
self.token = self.token_manager.fetch_access_token()

def is_token_expired(self, token):
try:
decoded = jwt.decode(token, options={"verify_signature": False})
exp = decoded.get("exp")

if exp is None:
return True # No expiration time present, consider it expired

# Check if the expiration time has passed
return datetime.fromtimestamp(exp) < datetime.utcnow()

except jwt.DecodeError:
return True # Token is invalid
except Exception as e:
print(f"An error occurred: {e}")
return True
57 changes: 47 additions & 10 deletions twilio/base/client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from urllib.parse import urlparse, urlunparse

from twilio import __version__
from twilio.base.exceptions import TwilioException
from twilio.http import HttpClient
from twilio.http.http_client import TwilioHttpClient
from twilio.http.response import Response
from twilio.credential.credential_provider import CredentialProvider


class ClientBase(object):
Expand All @@ -23,6 +23,7 @@ def __init__(
environment: Optional[MutableMapping[str, str]] = None,
edge: Optional[str] = None,
user_agent_extensions: Optional[List[str]] = None,
credential_provider: Optional[CredentialProvider] = None,
):
"""
Initializes the Twilio Client
Expand All @@ -35,7 +36,9 @@ def __init__(
:param environment: Environment to look for auth details, defaults to os.environ
:param edge: Twilio Edge to make requests to, defaults to None
:param user_agent_extensions: Additions to the user agent string
:param credential_provider: credential provider for authentication method that needs to be used
"""

environment = environment or os.environ

self.username = username or environment.get("TWILIO_ACCOUNT_SID")
Expand All @@ -48,9 +51,8 @@ def __init__(
""" :type : str """
self.user_agent_extensions = user_agent_extensions or []
""" :type : list[str] """

if not self.username or not self.password:
raise TwilioException("Credentials are required to create a TwilioClient")
self.credential_provider = credential_provider or None
""" :type : CredentialProvider """

self.account_sid = account_sid or self.username
""" :type : str """
Expand Down Expand Up @@ -85,15 +87,27 @@ def request(
:returns: Response from the Twilio API
"""
auth = self.get_auth(auth)
headers = self.get_headers(method, headers)
uri = self.get_hostname(uri)

if self.credential_provider:

auth_strategy = self.credential_provider.to_auth_strategy()
headers["Authorization"] = auth_strategy.get_auth_string()
elif self.username is not None and self.password is not None:
auth = self.get_auth(auth)
else:
auth = None

if method == "DELETE":
del headers["Accept"]

uri = self.get_hostname(uri)
filtered_data = self.copy_non_none_values(data)
return self.http_client.request(
method,
uri,
params=params,
data=data,
data=filtered_data,
headers=headers,
auth=auth,
timeout=timeout,
Expand Down Expand Up @@ -132,21 +146,44 @@ async def request_async(
"http_client must be asynchronous to support async API requests"
)

auth = self.get_auth(auth)
headers = self.get_headers(method, headers)
uri = self.get_hostname(uri)
if method == "DELETE":
del headers["Accept"]

if self.credential_provider:
auth_strategy = self.credential_provider.to_auth_strategy()
headers["Authorization"] = auth_strategy.get_auth_string()
elif self.username is not None and self.password is not None:
auth = self.get_auth(auth)
else:
auth = None

uri = self.get_hostname(uri)
filtered_data = self.copy_non_none_values(data)
return await self.http_client.request(
method,
uri,
params=params,
data=data,
data=filtered_data,
headers=headers,
auth=auth,
timeout=timeout,
allow_redirects=allow_redirects,
)

def copy_non_none_values(self, data):
if isinstance(data, dict):
return {
k: self.copy_non_none_values(v)
for k, v in data.items()
if v is not None
}
elif isinstance(data, list):
return [
self.copy_non_none_values(item) for item in data if item is not None
]
return data

def get_auth(self, auth: Optional[Tuple[str, str]]) -> Tuple[str, str]:
"""
Get the request authentication object
Expand Down
2 changes: 2 additions & 0 deletions twilio/base/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ def load_page(self, payload: Dict[str, Any]):
key = keys - self.META_KEYS
if len(key) == 1:
return payload[key.pop()]
if "Resources" in payload:
return payload["Resources"]

raise TwilioException("Page Records can not be deserialized")

Expand Down
3 changes: 0 additions & 3 deletions twilio/base/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ async def fetch_async(
timeout=timeout,
allow_redirects=allow_redirects,
)

return self._parse_fetch(method, uri, response)

def _parse_update(self, method: str, uri: str, response: Response) -> Any:
Expand Down Expand Up @@ -461,7 +460,6 @@ def create(
timeout=timeout,
allow_redirects=allow_redirects,
)

return self._parse_create(method, uri, response)

async def create_async(
Expand All @@ -488,5 +486,4 @@ async def create_async(
timeout=timeout,
allow_redirects=allow_redirects,
)

return self._parse_create(method, uri, response)
Empty file added twilio/credential/__init__.py
Empty file.
13 changes: 13 additions & 0 deletions twilio/credential/credential_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from twilio.auth_strategy.auth_type import AuthType


class CredentialProvider:
def __init__(self, auth_type: AuthType):
self._auth_type = auth_type

@property
def auth_type(self) -> AuthType:
return self._auth_type

def to_auth_strategy(self):
raise NotImplementedError("Subclasses must implement this method")
28 changes: 28 additions & 0 deletions twilio/credential/orgs_credential_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from twilio.http.orgs_token_manager import OrgTokenManager
from twilio.base.exceptions import TwilioException
from twilio.credential.credential_provider import CredentialProvider
from twilio.auth_strategy.auth_type import AuthType
from twilio.auth_strategy.token_auth_strategy import TokenAuthStrategy


class OrgsCredentialProvider(CredentialProvider):
def __init__(self, client_id: str, client_secret: str, token_manager=None):
super().__init__(AuthType.CLIENT_CREDENTIALS)

if client_id is None or client_secret is None:
raise TwilioException("Client id and Client secret are mandatory")

self.grant_type = "client_credentials"
self.client_id = client_id
self.client_secret = client_secret
self.token_manager = token_manager
self.auth_strategy = None

def to_auth_strategy(self):
if self.token_manager is None:
self.token_manager = OrgTokenManager(
self.grant_type, self.client_id, self.client_secret
)
if self.auth_strategy is None:
self.auth_strategy = TokenAuthStrategy(self.token_manager)
return self.auth_strategy
6 changes: 3 additions & 3 deletions twilio/http/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,11 @@ def request(
}
if headers and headers.get("Content-Type") == "application/json":
kwargs["json"] = data
elif headers and headers.get("Content-Type") == "application/scim+json":
kwargs["json"] = data
else:
kwargs["data"] = data
self.log_request(kwargs)

self._test_only_last_response = None
session = self.session or Session()
request = Request(**kwargs)
Expand All @@ -102,12 +103,11 @@ def request(
settings = session.merge_environment_settings(
prepped_request.url, self.proxy, None, None, None
)

response = session.send(
prepped_request,
allow_redirects=allow_redirects,
timeout=timeout,
**settings
**settings,
)

self.log_response(response.status_code, response)
Expand Down
41 changes: 41 additions & 0 deletions twilio/http/orgs_token_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from twilio.http.token_manager import TokenManager
from twilio.rest import Client


class OrgTokenManager(TokenManager):
"""
Orgs Token Manager
"""

def __init__(
self,
grant_type: str,
client_id: str,
client_secret: str,
code: str = None,
redirect_uri: str = None,
audience: str = None,
refreshToken: str = None,
scope: str = None,
):
self.grant_type = grant_type
self.client_id = client_id
self.client_secret = client_secret
self.code = code
self.redirect_uri = redirect_uri
self.audience = audience
self.refreshToken = refreshToken
self.scope = scope
self.client = Client()

def fetch_access_token(self):
token_instance = self.client.preview_iam.v1.token.create(
grant_type=self.grant_type,
client_id=self.client_id,
client_secret=self.client_secret,
code=self.code,
redirect_uri=self.redirect_uri,
audience=self.audience,
scope=self.scope,
)
return token_instance.access_token
7 changes: 7 additions & 0 deletions twilio/http/token_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from twilio.base.version import Version


class TokenManager:

def fetch_access_token(self, version: Version):
pass
Loading

0 comments on commit 6e78c78

Please sign in to comment.