@@ -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+
108238class AsyncServiceNowJWTAuth (httpx .Auth ):
109239 """
110240 JWT-based authentication for async client
0 commit comments