Skip to content

Commit 60d071e

Browse files
authored
Merge pull request #140 from ServiceNow/scratch/136-oauth
Add OAuth 2.0 Client Credentials Grant Flow support
2 parents 7f85e4c + 9aba6a9 commit 60d071e

File tree

6 files changed

+412
-5
lines changed

6 files changed

+412
-5
lines changed

pysnc/asyncio/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
AsyncServiceNowFlow,
99
AsyncServiceNowJWTAuth,
1010
AsyncServiceNowPasswordGrantFlow,
11+
AsyncServiceNowClientCredentialsFlow,
1112
)
1213
from .client import (
1314
AsyncAttachmentAPI,
@@ -28,5 +29,6 @@
2829
"AsyncAttachment",
2930
"AsyncServiceNowFlow",
3031
"AsyncServiceNowPasswordGrantFlow",
32+
"AsyncServiceNowClientCredentialsFlow",
3133
"AsyncServiceNowJWTAuth",
3234
]

pysnc/asyncio/auth.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,136 @@ async def authenticate(self, instance: str, **kwargs) -> httpx.AsyncClient: # t
105105
return client
106106

107107

108+
class AsyncServiceNowClientCredentialsFlow(AsyncServiceNowFlow):
109+
"""
110+
OAuth2 Client Credentials Grant Flow for async ServiceNow client.
111+
112+
This flow is ideal for machine-to-machine authentication where no user context is needed.
113+
Only requires client_id and client_secret (no username/password).
114+
115+
Example:
116+
>>> flow = AsyncServiceNowClientCredentialsFlow('my_client_id', 'my_client_secret')
117+
>>> client = AsyncServiceNowClient('dev12345', flow)
118+
"""
119+
120+
def __init__(self, client_id: str, client_secret: str):
121+
"""
122+
Client Credentials flow authentication (OAuth 2.0)
123+
124+
:param client_id: The OAuth application client ID
125+
:param client_secret: The OAuth application client secret
126+
"""
127+
self.client_id = client_id
128+
self.__secret = client_secret
129+
self.__token: Optional[str] = None
130+
self.__expires_at: Optional[float] = None
131+
132+
def authorization_url(self, authorization_base_url: str) -> str:
133+
"""Generate the token endpoint URL"""
134+
return f"{authorization_base_url}/oauth_token.do"
135+
136+
async def _get_access_token(self, instance: str) -> str:
137+
"""
138+
Request an access token from ServiceNow using client credentials.
139+
140+
:param instance: The ServiceNow instance URL
141+
:return: Access token string
142+
:raises AuthenticationException: If token request fails
143+
"""
144+
token_url = self.authorization_url(instance)
145+
headers = {
146+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
147+
}
148+
data = {
149+
'grant_type': 'client_credentials',
150+
'client_id': self.client_id,
151+
'client_secret': self.__secret
152+
}
153+
154+
async with httpx.AsyncClient() as client:
155+
try:
156+
r = await client.post(token_url, headers=headers, data=data, timeout=30.0)
157+
except httpx.RequestError as e:
158+
raise AuthenticationException(f"Failed to connect to token endpoint: {e}")
159+
160+
if r.status_code != 200:
161+
try:
162+
error_data = r.json()
163+
error_msg = error_data.get('error_description', error_data.get('error', r.text))
164+
except Exception:
165+
error_msg = r.text
166+
raise AuthenticationException(
167+
f"Failed to obtain access token: {r.status_code} {r.reason_phrase} - {error_msg}"
168+
)
169+
170+
try:
171+
token_data = r.json()
172+
except Exception:
173+
raise AuthenticationException(f"Invalid JSON response from token endpoint: {r.text}")
174+
175+
if 'access_token' not in token_data:
176+
raise AuthenticationException(f"No access_token in response: {token_data}")
177+
178+
self.__token = token_data['access_token']
179+
# Use expires_in from response, default to 3600 seconds (1 hour) if not provided
180+
expires_in = token_data.get('expires_in', 3600)
181+
# Refresh 60 seconds before actual expiry to avoid edge cases
182+
self.__expires_at = time.time() + expires_in - 60
183+
184+
return self.__token
185+
186+
async def authenticate(self, instance: str, **kwargs) -> httpx.AsyncClient:
187+
"""
188+
Create and return an authenticated httpx.AsyncClient with Bearer token.
189+
The client will automatically refresh the token when it expires.
190+
191+
:param instance: The ServiceNow instance URL
192+
:param kwargs: Additional arguments (proxies, verify, timeout, etc.)
193+
:return: Authenticated httpx.AsyncClient
194+
"""
195+
verify = kwargs.get("verify", True)
196+
proxies = kwargs.get("proxies", None)
197+
timeout = kwargs.get("timeout", 30.0)
198+
199+
# Get initial token
200+
if not self.__token or (self.__expires_at is not None and time.time() > self.__expires_at):
201+
await self._get_access_token(instance)
202+
203+
# Create client with custom auth handler that refreshes tokens
204+
client = httpx.AsyncClient(
205+
base_url=instance,
206+
headers={"Accept": "application/json"},
207+
auth=_AsyncClientCredentialsAuth(self, instance),
208+
verify=verify,
209+
proxy=proxies,
210+
timeout=timeout,
211+
follow_redirects=True,
212+
)
213+
214+
return client
215+
216+
217+
class _AsyncClientCredentialsAuth(httpx.Auth):
218+
"""
219+
Internal auth handler that automatically refreshes client credentials tokens for async client.
220+
"""
221+
222+
def __init__(self, flow: AsyncServiceNowClientCredentialsFlow, instance: str):
223+
self._flow = flow
224+
self._instance = instance
225+
226+
async def async_auth_flow(self, request: httpx.Request):
227+
"""httpx Auth flow that checks and refreshes token before each request"""
228+
# Check if token needs refresh
229+
_token = self._flow._AsyncServiceNowClientCredentialsFlow__token # type: ignore[attr-defined]
230+
_expires_at = self._flow._AsyncServiceNowClientCredentialsFlow__expires_at # type: ignore[attr-defined]
231+
if not _token or (_expires_at is not None and time.time() > _expires_at):
232+
await self._flow._get_access_token(self._instance)
233+
234+
request.headers['Authorization'] = f"Bearer {_token}"
235+
yield request
236+
237+
108238
class AsyncServiceNowJWTAuth(httpx.Auth):
109239
"""
110240
JWT-based authentication for async client

pysnc/asyncio/client.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,29 @@ def __init__(self, instance, auth, proxy=None, verify=None, cert=None, auto_retr
9090
self.__session = auth
9191
# best-effort header merge
9292
self.__session.headers.update(headers)
93-
elif isinstance(auth, AsyncServiceNowFlow): # accept either, adapt
94-
raise NotImplementedError("AsyncServiceNowFlow is not supported yet for async client")
93+
elif isinstance(auth, AsyncServiceNowFlow):
94+
# Use the async flow to authenticate and get a client
95+
import asyncio
96+
# We need to run the async authenticate method
97+
# This is a bit awkward but necessary for __init__
98+
loop = None
99+
try:
100+
loop = asyncio.get_running_loop()
101+
except RuntimeError:
102+
pass
103+
104+
if loop and loop.is_running():
105+
# If we're already in an async context, we can't use run_until_complete
106+
# Store the flow and defer authentication
107+
self.__pending_flow = auth
108+
self.__session = None
109+
else:
110+
# Not in an async context, we can authenticate now
111+
self.__session = asyncio.run(auth.authenticate(
112+
self.__instance,
113+
proxies=self.__proxy_url,
114+
verify=verify if verify is not None else True
115+
))
95116
elif cert is not None:
96117
# cert-only client (no auth)
97118
self.__session = httpx.AsyncClient(

pysnc/auth.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
88

9+
__all__ = [
10+
'ServiceNowFlow',
11+
'ServiceNowPasswordGrantFlow',
12+
'ServiceNowClientCredentialsFlow',
13+
'ServiceNowJWTAuth',
14+
]
15+
916

1017
class ServiceNowFlow:
1118
def authenticate(self, instance: str, **kwargs) -> requests.Session:
@@ -56,6 +63,123 @@ def authenticate(self, instance: str, **kwargs) -> requests.Session:
5663
raise AuthenticationException('Install dependency requests-oauthlib')
5764

5865

66+
class ServiceNowClientCredentialsFlow(ServiceNowFlow):
67+
"""
68+
OAuth2 Client Credentials Grant Flow for ServiceNow.
69+
70+
This flow is ideal for machine-to-machine authentication where no user context is needed.
71+
Only requires client_id and client_secret (no username/password).
72+
73+
Example:
74+
>>> flow = ServiceNowClientCredentialsFlow('my_client_id', 'my_client_secret')
75+
>>> client = ServiceNowClient('dev12345', flow)
76+
"""
77+
78+
def __init__(self, client_id, client_secret):
79+
"""
80+
Client Credentials flow authentication (OAuth 2.0)
81+
82+
:param client_id: The OAuth application client ID
83+
:param client_secret: The OAuth application client secret
84+
"""
85+
self.client_id = client_id
86+
self.__secret = client_secret
87+
self.__token = None
88+
self.__expires_at = None
89+
90+
def authorization_url(self, authorization_base_url):
91+
"""Generate the token endpoint URL"""
92+
return f"{authorization_base_url}/oauth_token.do"
93+
94+
def _get_access_token(self, instance):
95+
"""
96+
Request an access token from ServiceNow using client credentials.
97+
98+
:param instance: The ServiceNow instance URL
99+
:return: Access token string
100+
:raises AuthenticationException: If token request fails
101+
"""
102+
token_url = self.authorization_url(instance)
103+
headers = {
104+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
105+
}
106+
data = {
107+
'grant_type': 'client_credentials',
108+
'client_id': self.client_id,
109+
'client_secret': self.__secret
110+
}
111+
112+
try:
113+
r = requests.post(token_url, headers=headers, data=data, timeout=30)
114+
except requests.exceptions.RequestException as e:
115+
raise AuthenticationException(f"Failed to connect to token endpoint: {e}")
116+
117+
if r.status_code != 200:
118+
try:
119+
error_data = r.json()
120+
error_msg = error_data.get('error_description', error_data.get('error', r.text))
121+
except ValueError:
122+
error_msg = r.text
123+
raise AuthenticationException(
124+
f"Failed to obtain access token: {r.status_code} {r.reason} - {error_msg}"
125+
)
126+
127+
try:
128+
token_data = r.json()
129+
except ValueError:
130+
raise AuthenticationException(f"Invalid JSON response from token endpoint: {r.text}")
131+
132+
if 'access_token' not in token_data:
133+
raise AuthenticationException(f"No access_token in response: {token_data}")
134+
135+
self.__token = token_data['access_token']
136+
# Use expires_in from response, default to 3600 seconds (1 hour) if not provided
137+
expires_in = token_data.get('expires_in', 3600)
138+
# Refresh 60 seconds before actual expiry to avoid edge cases
139+
self.__expires_at = int(time.time() + expires_in - 60)
140+
141+
return self.__token
142+
143+
def authenticate(self, instance: str, **kwargs) -> requests.Session:
144+
"""
145+
Create and return an authenticated requests.Session with Bearer token.
146+
The session will automatically refresh the token when it expires.
147+
148+
:param instance: The ServiceNow instance URL
149+
:param kwargs: Additional arguments (proxies, verify, etc.)
150+
:return: Authenticated requests.Session
151+
"""
152+
session = requests.Session()
153+
154+
# Get initial token
155+
if not self.__token or time.time() > (self.__expires_at or 0):
156+
self._get_access_token(instance)
157+
158+
# Use a custom auth handler that refreshes tokens
159+
session.auth = _ClientCredentialsAuth(self, instance)
160+
161+
return session
162+
163+
164+
class _ClientCredentialsAuth(AuthBase):
165+
"""
166+
Internal auth handler that automatically refreshes client credentials tokens.
167+
"""
168+
169+
def __init__(self, flow, instance):
170+
self._flow = flow
171+
self._instance = instance
172+
173+
def __call__(self, request):
174+
# Check if token needs refresh
175+
if not self._flow._ServiceNowClientCredentialsFlow__token or \
176+
time.time() > (self._flow._ServiceNowClientCredentialsFlow__expires_at or 0):
177+
self._flow._get_access_token(self._instance)
178+
179+
request.headers['Authorization'] = f"Bearer {self._flow._ServiceNowClientCredentialsFlow__token}"
180+
return request
181+
182+
59183
class ServiceNowJWTAuth(AuthBase):
60184

61185
def __init__(self, client_id, client_secret, jwt):

0 commit comments

Comments
 (0)