Skip to content

Commit 3bf781c

Browse files
Merge pull request #118 from contentstack/staging
DX | 22-09-2025 | Release
2 parents cc9dabc + ae6e1c5 commit 3bf781c

File tree

16 files changed

+1407
-33
lines changed

16 files changed

+1407
-33
lines changed

.talismanrc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,4 +393,14 @@ fileignoreconfig:
393393
checksum: 344aa9e4b3ec399c581a507eceff63ff6faf56ad938475e5f4865f6cb590df68
394394
- filename: tests/unit/contentstack/test_contentstack.py
395395
checksum: 98503cbd96cb546a19aed037a6ca28ef54fcea312efcd9bac1171e43760f6e86
396+
- filename: contentstack_management/contentstack.py
397+
checksum: 591978d70ecbe5fc3e6587544e9c112a6cd85fd8da2051b48ff87ab6a2e9eb57
398+
- filename: tests/unit/test_oauth_handler.py
399+
checksum: 8b6853ba64c3de4f9097ca506719c5e33c7468ae5985b8adcda3eb6461d76be5
400+
- filename: contentstack_management/oauth/oauth_handler.py
401+
checksum: e33cfd32d90c0553c4959c0d266fef1247cd0e0fe7bbe85cae98bb205e62c70e
402+
- filename: tests/unit/user_session/test_user_session_totp.py
403+
checksum: 0db30c5a306783b10d345d73cff3c61490d7cbc47273623df47e6849c3e97002
404+
- filename: tests/unit/contentstack/test_totp_login.py
405+
checksum: cefad0ddc1a2db1bf59d6e04501c4381acc8b44fad1e5e2e24c06e33d827c859
396406
version: "1.0"

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# CHANGELOG
22

33
## Content Management SDK For Python
4+
5+
---
6+
## v1.7.0
7+
8+
#### Date: 15 September 2025
9+
10+
- OAuth 2.0 support.
411
---
512
## v1.6.0
613

contentstack_management/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
from .extensions.extension import Extension
3535
from .variant_group.variant_group import VariantGroup
3636
from .variants.variants import Variants
37+
from .oauth.oauth_handler import OAuthHandler
38+
from .oauth.oauth_interceptor import OAuthInterceptor
3739

3840

3941
__all__ = (
@@ -71,14 +73,16 @@
7173
"PublishQueue",
7274
"Extension",
7375
"VariantGroup",
74-
"Variants"
76+
"Variants",
77+
"OAuthHandler",
78+
"OAuthInterceptor"
7579
)
7680

7781
__title__ = 'contentstack-management-python'
7882
__author__ = 'dev-ex'
7983
__status__ = 'debug'
8084
__region__ = 'na'
81-
__version__ = '1.6.0'
85+
__version__ = '1.7.0'
8286
__host__ = 'api.contentstack.io'
8387
__protocol__ = 'https://'
8488
__api_version__ = 'v3'

contentstack_management/_api_client.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33

44
class _APIClient:
5-
def __init__(self, endpoint, headers, timeout=30, max_retries: int = 5):
5+
def __init__(self, endpoint, headers, timeout=30, max_retries: int = 5, oauth_interceptor=None):
66
"""
77
The function is a constructor that initializes the endpoint, headers, timeout, and max_retries
88
attributes of an object.
@@ -25,6 +25,8 @@ def __init__(self, endpoint, headers, timeout=30, max_retries: int = 5):
2525
self.headers = headers
2626
self.timeout = timeout
2727
self.max_retries = max_retries
28+
self.oauth_interceptor = oauth_interceptor
29+
self.oauth = {} # OAuth token storage
2830
pass
2931

3032
def _call_request(self, method, url, headers: dict = None, params=None, data=None, json_data=None, files=None):
@@ -52,9 +54,17 @@ def _call_request(self, method, url, headers: dict = None, params=None, data=Non
5254
:return: the JSON response from the HTTP request.
5355
"""
5456

55-
# headers.update(self.headers)
57+
if self.oauth_interceptor and self.oauth_interceptor.is_oauth_configured():
58+
return self.oauth_interceptor.execute_request(
59+
method, url, headers=headers, params=params, data=data,
60+
json=json_data, files=files, timeout=self.timeout
61+
)
62+
63+
if headers is None:
64+
headers = {}
65+
headers.update(self.headers) # Merge client headers (including authtoken) with request headers
5666
response = requests.request(
57-
method, url, headers=headers, params=params, data=data, json=json_data, files=files)
67+
method, url, headers=headers, params=params, data=data, json=json_data, files=files, timeout=self.timeout)
5868
# response.raise_for_status()
5969
return response
6070

contentstack_management/contentstack.py

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from enum import Enum
2+
import os
3+
import pyotp
24
from ._api_client import _APIClient
35
from contentstack_management.organizations import organization
46
from contentstack_management.stack import stack
57
from contentstack_management.user_session import user_session
68
from contentstack_management.users import user
9+
from contentstack_management.oauth.oauth_handler import OAuthHandler
710

811
version = '0.0.1'
912

@@ -33,14 +36,16 @@ class Client:
3336
def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://',
3437
authtoken: str = None , management_token=None, headers: dict = None,
3538
region: Region = Region.US.value, version='v3', timeout=2, max_retries: int = 18, early_access: list = None,
36-
**kwargs):
39+
oauth_config: dict = None, **kwargs):
3740
self.endpoint = 'https://api.contentstack.io/v3/'
38-
if region is not None and host is not None and region is not Region.US.value:
39-
self.endpoint = f'{scheme}{region}-{host}/{version}/'
40-
if region is not None and host is None and region is not Region.US.value:
41-
host = 'api.contentstack.com'
42-
self.endpoint = f'{scheme}{region}-{host}/{version}/'
43-
if host is not None and region is None:
41+
42+
if region is not None and region is not Region.US.value:
43+
if host is not None and host != 'api.contentstack.io':
44+
self.endpoint = f'{scheme}{region}-api.{host}/{version}/'
45+
else:
46+
host = 'api.contentstack.com'
47+
self.endpoint = f'{scheme}{region}-{host}/{version}/'
48+
elif host is not None and host != 'api.contentstack.io':
4449
self.endpoint = f'{scheme}{host}/{version}/'
4550
if headers is None:
4651
headers = {}
@@ -55,6 +60,19 @@ def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://',
5560
headers['authorization'] = management_token
5661
headers = user_agents(headers)
5762
self.client = _APIClient(endpoint=self.endpoint, headers=headers, timeout=timeout, max_retries=max_retries)
63+
64+
# Initialize OAuth if configuration is provided
65+
self.oauth_handler = None
66+
if oauth_config:
67+
self.oauth_handler = OAuthHandler(
68+
app_id=oauth_config.get('app_id'),
69+
client_id=oauth_config.get('client_id'),
70+
redirect_uri=oauth_config.get('redirect_uri'),
71+
response_type=oauth_config.get('response_type', 'code'),
72+
client_secret=oauth_config.get('client_secret'),
73+
scope=oauth_config.get('scope'),
74+
api_client=self.client
75+
)
5876

5977
"""
6078
:param host: Optional hostname for the API endpoint.
@@ -77,9 +95,36 @@ def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://',
7795
-------------------------------
7896
"""
7997

80-
def login(self, email: str, password: str, tfa_token: str = None):
81-
return user_session.UserSession(self.client).login(email, password, tfa_token)
82-
pass
98+
def login(self, email: str, password: str, tfa_token: str = None, mfa_secret: str = None):
99+
"""
100+
Login to Contentstack with optional TOTP support.
101+
102+
:param email: User's email address
103+
:param password: User's password
104+
:param tfa_token: Optional two-factor authentication token
105+
:param mfa_secret: Optional MFA secret for automatic TOTP generation.
106+
If not provided, will check MFA_SECRET environment variable
107+
:return: Response object from the login request
108+
"""
109+
final_tfa_token = tfa_token
110+
111+
if not mfa_secret:
112+
mfa_secret = os.getenv('MFA_SECRET')
113+
114+
if mfa_secret and not tfa_token:
115+
final_tfa_token = self._generate_totp(mfa_secret)
116+
117+
return user_session.UserSession(self.client).login(email, password, final_tfa_token)
118+
119+
def _generate_totp(self, secret: str) -> str:
120+
"""
121+
Generate a Time-Based One-Time Password (TOTP) from the provided secret.
122+
123+
:param secret: The MFA secret key for TOTP generation
124+
:return: The current TOTP code as a string
125+
"""
126+
totp = pyotp.TOTP(secret)
127+
return totp.now()
83128

84129
def logout(self):
85130
return user_session.UserSession(client=self.client).logout()
@@ -96,3 +141,41 @@ def organizations(self, organization_uid: str = None):
96141

97142
def stack(self, api_key: str = None):
98143
return stack.Stack(self.client, api_key)
144+
145+
def oauth(self, app_id: str, client_id: str, redirect_uri: str,
146+
response_type: str = "code", client_secret: str = None,
147+
scope: list = None):
148+
"""
149+
Create an OAuth handler for OAuth 2.0 authentication.
150+
151+
Args:
152+
app_id: Your registered App ID
153+
client_id: Your OAuth Client ID
154+
redirect_uri: The URL where the user is redirected after login and consent
155+
response_type: OAuth response type (default: "code")
156+
client_secret: Client secret for standard OAuth flows (optional for PKCE)
157+
scope: Permissions requested (optional)
158+
159+
Returns:
160+
OAuthHandler instance
161+
162+
Example:
163+
>>> import contentstack_management
164+
>>> client = contentstack_management.Client()
165+
>>> oauth_handler = client.oauth(
166+
... app_id='your-app-id',
167+
... client_id='your-client-id',
168+
... redirect_uri='http://localhost:3000/callback'
169+
... )
170+
>>> auth_url = oauth_handler.authorize()
171+
>>> print(f"Visit this URL to authorize: {auth_url}")
172+
"""
173+
return OAuthHandler(
174+
app_id=app_id,
175+
client_id=client_id,
176+
redirect_uri=redirect_uri,
177+
response_type=response_type,
178+
client_secret=client_secret,
179+
scope=scope,
180+
api_client=self.client
181+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""OAuth 2.0 authentication module for Contentstack Management SDK."""
2+
3+
from .oauth_handler import OAuthHandler
4+
5+
__all__ = ["OAuthHandler"]

0 commit comments

Comments
 (0)