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: Organizations Api uptake for twilio-python #815

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b4c5734
feat: oauth sdk implementation (#799)
manisha1997 Jul 23, 2024
3e246e4
Python Orgs Api Changes
AsabuHere Sep 17, 2024
8395487
removing unwanted logs
AsabuHere Sep 17, 2024
bc5c16b
removing unwanted logs
AsabuHere Sep 17, 2024
a66f9e9
removing unwanted logs
AsabuHere Sep 17, 2024
b5a6490
removing unwanted logs
AsabuHere Sep 17, 2024
fac26ee
Fixing token fetch flow
AsabuHere Sep 17, 2024
15e15c0
twilio python changes for orgs api uptake
AsabuHere Sep 26, 2024
7b07ba7
twilio python changes for orgs api uptake
AsabuHere Sep 26, 2024
af11fd2
Update test_cluster.py
AsabuHere Sep 26, 2024
661785d
Update test_cluster.py
AsabuHere Sep 26, 2024
6a8c2d8
twilio python changes for orgs api uptake
AsabuHere Sep 26, 2024
1ba2f9b
twilio python changes for orgs api uptake
AsabuHere Sep 26, 2024
98708f0
twilio python changes for orgs api uptake
AsabuHere Sep 26, 2024
d78d5d5
twilio python changes for orgs api uptake
AsabuHere Sep 26, 2024
7bdf1b5
Merge branch 'main' into asabu_Python_changes
AsabuHere Sep 26, 2024
bc77770
twilio python changes for orgs api uptake
AsabuHere Sep 27, 2024
0211f23
twilio python changes for orgs api uptake
AsabuHere Sep 27, 2024
27dec32
twilio python changes for orgs api uptake
AsabuHere Sep 27, 2024
2959689
twilio python changes for orgs api uptake
AsabuHere Sep 27, 2024
b973065
Uptake of review comments
AsabuHere Oct 1, 2024
ceebd46
modified error messages
AsabuHere Oct 1, 2024
35b5015
Uptake of review comments
AsabuHere Oct 6, 2024
76fecab
Merge branch 'main' into asabu_Python_changes
AsabuHere Oct 6, 2024
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
21 changes: 21 additions & 0 deletions twilio/authStrategy/authStrategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from twilio.authStrategy.authType import AuthType
from enum import Enum
from abc import abstractmethod
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved


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

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

def get_auth_string(self) -> str:
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
"""Return the authentication string."""
pass

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

class AuthType(Enum):
TOKEN = 'token'
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
NO_AUTH = 'noauth'
BASIC = 'basic'
API_KEY = 'api_key'
CLIENT_CREDENTIALS = 'client_credentials'

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

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
35 changes: 35 additions & 0 deletions twilio/authStrategy/tokenAuthStrategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import jwt
import threading
from datetime import datetime, timedelta

from twilio.authStrategy.authType import AuthType
from twilio.authStrategy.authStrategy import AuthStrategy
from twilio.http.token_manager import TokenManager


class TokenAuthStrategy(AuthStrategy):
def __init__(self, token_manager: TokenManager):
super().__init__(AuthType.TOKEN)
self.token_manager = token_manager
self.token = None
self.lock = threading.Lock()

def get_auth_string(self) -> str:
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.token = self.token_manager.fetch_access_token()

def is_token_expired(self, token):
decoded_jwt = jwt.decode(token, options={"verify_signature": True})
expires_at = decoded_jwt.get("exp")
# Add a buffer of 30 seconds
buffer_seconds = 30
buffer_expires_at = expires_at - buffer_seconds
return buffer_expires_at < datetime.datetime.now().timestamp()
40 changes: 33 additions & 7 deletions twilio/base/client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
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.authStrategy.authType import AuthType
from twilio.credential.credentialProvider import CredentialProvider


class ClientBase(object):
Expand All @@ -23,6 +24,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 +37,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 +52,8 @@ def __init__(
""" :type : str """
self.user_agent_extensions = user_agent_extensions or []
""" :type : list[str] """

AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
if not self.username or not self.password:
raise TwilioException("Credentials are required to create a TwilioClient")
self.credential_provider = credential_provider or None
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Check this with Kridai, if existing customers use TwilioException - this is a breaking change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Check if the exception being thrown as 401 is getting wrapped in TwilioException and being sent to customer

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For the below cases

  • No username password provided
  • Wrong username password provided
    TwilioRestException is thrown now

Is this a breaking change?

""" :type : CredentialProvider """

self.account_sid = account_sid or self.username
""" :type : str """
Expand Down Expand Up @@ -85,8 +88,21 @@ def request(

:returns: Response from the Twilio API
"""
auth = self.get_auth(auth)

headers = self.get_headers(method, headers)

##If credential provider is provided by user, get the associated auth strategy
##Using the auth strategy, fetch the auth string and set it to authorization header
auth_strategy = None ##Initialization
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
if self.credential_provider:
auth_strategy = self.credential_provider.to_auth_strategy()
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
if auth_strategy.auth_type == AuthType.TOKEN:
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
auth_strategy.fetch_token()
headers["Authorization"] = auth_strategy.get_auth_string()
else:
auth = self.get_auth(auth)
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved


uri = self.get_hostname(uri)

return self.http_client.request(
Expand Down Expand Up @@ -132,9 +148,19 @@ 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)
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved

##If credential provider is provided by user, get the associated auth strategy
##Using the auth strategy, fetch the auth string and set it to authorization header
auth_strategy = None ##Initialization
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
if self.credential_provider:
auth_strategy = self.credential_provider.to_auth_strategy()
if auth_strategy.auth_type == AuthType.TOKEN:
auth_strategy.fetch_token()
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
headers["Authorization"] = auth_strategy.get_auth_string()
else:
auth = self.get_auth(auth)

return await self.http_client.request(
method,
Expand Down
2 changes: 0 additions & 2 deletions twilio/base/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,6 @@ def create(
timeout=timeout,
allow_redirects=allow_redirects,
)

return self._parse_create(method, uri, response)

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

return self._parse_create(method, uri, response)
12 changes: 12 additions & 0 deletions twilio/credential/credentialProvider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from twilio.authStrategy.authType 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")
26 changes: 26 additions & 0 deletions twilio/credential/orgsCredentialProvider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@

AsabuHere marked this conversation as resolved.
Show resolved Hide resolved

from twilio.http.orgs_token_manager import OrgTokenManager
from twilio.base.exceptions import TwilioException
from twilio.credential.credentialProvider import CredentialProvider
from twilio.authStrategy.authType import AuthType
from twilio.authStrategy.tokenAuthStrategy 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("Invalid credentials passed")
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved

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

def to_auth_strategy(self):
if self.token_manager is None:
self.token_manager = OrgTokenManager(self.grant_type, self.client_id, self.client_secret)

return TokenAuthStrategy(self.token_manager)
30 changes: 30 additions & 0 deletions twilio/http/bearer_token_http_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import datetime
import jwt

from twilio.base.version import Version
from twilio.http.token_manager import TokenManager
from twilio.twilio_bearer_token_auth import TwilioBearerTokenAuth
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved


class BearerTokenHTTPClient:
def __init__(self, orgs_token_manager: TokenManager):
self.orgs_token_manager = orgs_token_manager

def get_access_token(self, version: Version):
if TwilioBearerTokenAuth.get_access_token() is None or self.is_token_expired(
TwilioBearerTokenAuth.get_access_token()
):
access_token = self.orgs_token_manager.fetch_access_token(version)
TwilioBearerTokenAuth.init(access_token)
else:
access_token = TwilioBearerTokenAuth.get_access_token()

return access_token

def is_token_expired(self, token):
decoded_jwt = jwt.decode(token, options={"verify_signature": True})
expires_at = decoded_jwt.get("exp")
# Add a buffer of 30 seconds
buffer_seconds = 30
buffer_expires_at = expires_at - buffer_seconds
return buffer_expires_at < datetime.datetime.now().timestamp()
7 changes: 5 additions & 2 deletions twilio/http/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from twilio.http import HttpClient
from twilio.http.request import Request as TwilioRequest
from twilio.http.response import Response
from twilio.authStrategy.authStrategy import AuthStrategy

_logger = logging.getLogger("twilio.http_client")

Expand Down Expand Up @@ -78,6 +79,10 @@ def request(
elif timeout <= 0:
raise ValueError(timeout)

if "Requires-Authentication" in headers:
headers.pop("Requires-Authentication", None)
auth = None

kwargs = {
"method": method.upper(),
"url": url,
Expand All @@ -91,7 +96,6 @@ def request(
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,7 +106,6 @@ def request(
settings = session.merge_environment_settings(
prepped_request.url, self.proxy, None, None, None
)

response = session.send(
prepped_request,
allow_redirects=allow_redirects,
Expand Down
43 changes: 43 additions & 0 deletions twilio/http/orgs_token_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from twilio.base.version import Version
from twilio.http.token_manager import TokenManager
from twilio.rest.preview_iam.v1.token import TokenList
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
16 changes: 16 additions & 0 deletions twilio/http/token_manager_initializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from twilio.http.token_manager import TokenManager


class TokenManagerInitializer:
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved

org_token_manager = None

@classmethod
def set_token_manager(cls, token_manager: TokenManager):
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
cls.org_token_manager = token_manager

@classmethod
AsabuHere marked this conversation as resolved.
Show resolved Hide resolved
def get_token_manager(cls):
if cls.org_token_manager is None:
raise Exception("Token Manager not initialized")
return cls.org_token_manager
4 changes: 3 additions & 1 deletion twilio/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def __init__(
environment=None,
edge=None,
user_agent_extensions=None,
credential_provider=None,
):
"""
Initializes the Twilio Client
Expand All @@ -120,6 +121,7 @@ def __init__(
environment,
edge,
user_agent_extensions,
credential_provider,
)

# Domains
Expand All @@ -132,7 +134,6 @@ def __init__(
self._events: Optional["Events"] = None
self._flex_api: Optional["FlexApi"] = None
self._frontline_api: Optional["FrontlineApi"] = None
self._preview_iam: Optional["PreviewIam"] = None
self._insights: Optional["Insights"] = None
self._intelligence: Optional["Intelligence"] = None
self._ip_messaging: Optional["IpMessaging"] = None
Expand All @@ -145,6 +146,7 @@ def __init__(
self._numbers: Optional["Numbers"] = None
self._oauth: Optional["Oauth"] = None
self._preview: Optional["Preview"] = None
self._preview_iam: Optional["PreviewIam"] = None
self._pricing: Optional["Pricing"] = None
self._proxy: Optional["Proxy"] = None
self._routes: Optional["Routes"] = None
Expand Down
Loading
Loading