pypomes-iam 0.2.9__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pypomes-iam might be problematic. Click here for more details.

@@ -0,0 +1,394 @@
1
+ import json
2
+ from flask import Request, Response, request, jsonify
3
+ from logging import Logger
4
+ from typing import Any
5
+
6
+ from .iam_common import (
7
+ IamServer, IamParam, _iam_lock,
8
+ _get_iam_registry, _get_public_key,
9
+ _iam_server_from_endpoint, _iam_server_from_issuer
10
+ )
11
+ from .iam_actions import (
12
+ action_login, action_logout,
13
+ action_token, action_exchange, action_callback
14
+ )
15
+ from .token_pomes import token_get_claims, token_validate
16
+
17
+ # the logger for IAM service operations
18
+ # (used exclusively at the HTTP endpoints - all other functions receive the logger as parameter)
19
+ __IAM_LOGGER: Logger | None = None
20
+
21
+
22
+ def jwt_required(func: callable) -> callable:
23
+ """
24
+ Create a decorator to authenticate service endpoints with JWT tokens.
25
+
26
+ :param func: the function being decorated
27
+ """
28
+ # ruff: noqa: ANN003 - Missing type annotation for *{name}
29
+ def wrapper(*args, **kwargs) -> Response:
30
+ response: Response = __request_validate(request=request)
31
+ return response if response else func(*args, **kwargs)
32
+
33
+ # prevent a rogue error ("View function mapping is overwriting an existing endpoint function")
34
+ wrapper.__name__ = func.__name__
35
+
36
+ return wrapper
37
+
38
+
39
+ def __request_validate(request: Request) -> Response:
40
+ """
41
+ Verify whether the HTTP *request* has the proper authorization, as per the JWT standard.
42
+
43
+ This implementation assumes that HTTP requests are handled with the *Flask* framework.
44
+ Because this code has a high usage frequency, only authentication failures are logged.
45
+
46
+ :param request: the *request* to be verified
47
+ :return: *None* if the *request* is valid, otherwise a *Response* reporting the error
48
+ """
49
+ # initialize the return variable
50
+ result: Response | None = None
51
+
52
+ # retrieve the authorization from the request header
53
+ auth_header: str = request.headers.get("Authorization")
54
+
55
+ # validate the authorization token
56
+ bad_token: bool = True
57
+ if auth_header and auth_header.startswith("Bearer "):
58
+ # extract and validate the JWT access token
59
+ token: str = auth_header.split(" ")[1]
60
+ claims: dict[str, Any] = token_get_claims(token=token)
61
+ if claims:
62
+ issuer: str = claims["payload"].get("iss")
63
+ recipient_attr: str | None = None
64
+ recipient_id: str = request.values.get("user-id") or request.values.get("login")
65
+ with _iam_lock:
66
+ iam_server: IamServer = _iam_server_from_issuer(issuer=issuer,
67
+ errors=None,
68
+ logger=__IAM_LOGGER)
69
+ if iam_server:
70
+ # validate the token's recipient only if a user identification is provided
71
+ if recipient_id:
72
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
73
+ errors=None,
74
+ logger=__IAM_LOGGER)
75
+ if registry:
76
+ recipient_attr = registry[IamParam.RECIPIENT_ATTR]
77
+ public_key: str = _get_public_key(iam_server=iam_server,
78
+ errors=None,
79
+ logger=__IAM_LOGGER)
80
+ # validate the token (log errors, only)
81
+ errors: list[str] = []
82
+ if public_key and token_validate(token=token,
83
+ issuer=issuer,
84
+ recipient_id=recipient_id,
85
+ recipient_attr=recipient_attr,
86
+ public_key=public_key,
87
+ errors=errors):
88
+ # token is valid
89
+ bad_token = False
90
+ elif __IAM_LOGGER:
91
+ __IAM_LOGGER.error("; ".join(errors))
92
+ if bad_token and __IAM_LOGGER:
93
+ __IAM_LOGGER.error(f"Authorization refused for token {token}")
94
+
95
+ # deny the authorization
96
+ if bad_token:
97
+ result = Response(response="Authorization failed",
98
+ status=401)
99
+ return result
100
+
101
+
102
+ def logger_register(logger: Logger) -> None:
103
+ """
104
+ Register the logger for HTTP services.
105
+
106
+ :param logger: the logger to be registered
107
+ """
108
+ global __IAM_LOGGER
109
+ __IAM_LOGGER = logger
110
+
111
+
112
+ # @flask_app.route(rule=<login_endpoint>, # JUSBR_ENDPOINT_LOGIN
113
+ # methods=["GET"])
114
+ # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_ENDPOINT_LOGIN
115
+ # methods=["GET"])
116
+ def service_login() -> Response:
117
+ """
118
+ Entry point for the IAM server's login service.
119
+
120
+ These are the expected request parameters:
121
+ - user-id: optional, identifies the reference user (alias: 'login')
122
+ - redirect-uri: a parameter to be added to the query part of the returned URL
123
+
124
+ If provided, the user identification will be validated against the authorization data
125
+ returned by *iam_server* upon login. On success, the following JSON, containing the appropriate
126
+ URL for invoking the IAM server's authentication page, is returned:
127
+ {
128
+ "login-url": <login-url>
129
+ }
130
+
131
+ :return: *Response* with the URL for invoking the IAM server's authentication page, or *BAD REQUEST* if error
132
+ """
133
+ # declare the return variable
134
+ result: Response | None = None
135
+
136
+ # log the request
137
+ if __IAM_LOGGER:
138
+ __IAM_LOGGER.debug(msg=_log_init(request=request))
139
+
140
+ errors: list[str] = []
141
+ with _iam_lock:
142
+ # retrieve the IAM server
143
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
144
+ errors=errors,
145
+ logger=__IAM_LOGGER)
146
+ if iam_server:
147
+ # obtain the login URL
148
+ login_url: str = action_login(iam_server=iam_server,
149
+ args=request.args,
150
+ errors=errors,
151
+ logger=__IAM_LOGGER)
152
+ if login_url:
153
+ result = jsonify({"login-url": login_url})
154
+ if errors:
155
+ result = Response(response="; ".join(errors),
156
+ status=400)
157
+
158
+ # log the response
159
+ if __IAM_LOGGER:
160
+ __IAM_LOGGER.debug(msg=f"Response {result}, {result.get_data(as_text=True)}")
161
+
162
+ return result
163
+
164
+
165
+ # @flask_app.route(rule=<logout_endpoint>, # JUSBR_ENDPOINT_LOGOUT
166
+ # methods=["GET"])
167
+ # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_ENDPOINT_LOGOUT
168
+ # methods=["GET"])
169
+ def service_logout() -> Response:
170
+ """
171
+ Entry point for the IAM server's logout service.
172
+
173
+ The user is identified by the attribute *user-id* or "login", provided as a request parameter.
174
+ If successful, remove all data relating to the user from the *IAM* server's registry.
175
+ Otherwise, this operation fails silently, unless an error has ocurred.
176
+
177
+ :return: *Response NO CONTENT*, or *BAD REQUEST* if error
178
+ """
179
+ # declare the return variable
180
+ result: Response | None
181
+
182
+ # log the request
183
+ if __IAM_LOGGER:
184
+ __IAM_LOGGER.debug(msg=_log_init(request=request))
185
+
186
+ errors: list[str] = []
187
+ with _iam_lock:
188
+ # retrieve the IAM server
189
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
190
+ errors=errors,
191
+ logger=__IAM_LOGGER)
192
+ if iam_server:
193
+ # logout the user
194
+ action_logout(iam_server=iam_server,
195
+ args=request.args,
196
+ errors=errors,
197
+ logger=__IAM_LOGGER)
198
+ if errors:
199
+ result = Response(response="; ".join(errors),
200
+ status=400)
201
+ else:
202
+ result = Response(status=204)
203
+
204
+ # log the response
205
+ if __IAM_LOGGER:
206
+ __IAM_LOGGER.debug(msg=f"Response {result}")
207
+
208
+ return result
209
+
210
+
211
+ # @flask_app.route(rule=<callback_endpoint>, # JUSBR_ENDPOINT_CALLBACK
212
+ # methods=["GET", "POST"])
213
+ # @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_ENDPOINT_CALLBACK
214
+ # methods=["POST"])
215
+ def service_callback() -> Response:
216
+ """
217
+ Entry point for the callback from the IAM server on authentication operation.
218
+
219
+ This callback is invoked from a front-end application after a successful login at the
220
+ *IAM* server's login page, forwarding the data received. In a typical OAuth2 flow faction,
221
+ this data is then used to effectively obtain the token from the *IAM* server.
222
+
223
+ The relevant expected request arguments are:
224
+ - *state*: used to enhance security during the authorization process, typically to provide *CSRF* protection
225
+ - *code*: the temporary authorization code provided by the IAM server, to be exchanged for the token
226
+
227
+ On success, the returned *Response* will contain the following JSON:
228
+ {
229
+ "user-id": <reference-user-identification>,
230
+ "access-token": <token>
231
+ }
232
+
233
+ :return: *Response* containing the reference user identification and the token, or *BAD REQUEST*
234
+ """
235
+ # log the request
236
+ if __IAM_LOGGER:
237
+ __IAM_LOGGER.debug(msg=_log_init(request=request))
238
+
239
+ errors: list[str] = []
240
+ token_data: tuple[str, str] | None = None
241
+ with _iam_lock:
242
+ # retrieve the IAM server
243
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
244
+ errors=errors,
245
+ logger=__IAM_LOGGER)
246
+ if iam_server:
247
+ # process the callback operation
248
+ token_data = action_callback(iam_server=iam_server,
249
+ args=request.args,
250
+ errors=errors,
251
+ logger=__IAM_LOGGER)
252
+ result: Response
253
+ if errors:
254
+ result = jsonify({"errors": "; ".join(errors)})
255
+ result.status_code = 400
256
+ else:
257
+ result = jsonify({"user-id": token_data[0],
258
+ "access-token": token_data[1]})
259
+ if __IAM_LOGGER:
260
+ # log the response (the returned data is not logged, as it contains the token)
261
+ __IAM_LOGGER.debug(msg=f"Response {result}")
262
+
263
+ return result
264
+
265
+
266
+ # @flask_app.route(rule=<token_endpoint>, # JUSBR_ENDPOINT_TOKEN
267
+ # methods=["GET"])
268
+ # @flask_app.route(rule=<token_endpoint>, # KEYCLOAK_ENDPOINT_TOKEN
269
+ # methods=["GET"])
270
+ def service_token() -> Response:
271
+ """
272
+ Entry point for retrieving a token from the *IAM* server.
273
+
274
+ The user is identified by the attribute *user-id* or "login", provided as a request parameter.
275
+
276
+ On success, the returned *Response* will contain the following JSON:
277
+ {
278
+ "user-id": <reference-user-identification>,
279
+ "access-token": <token>
280
+ }
281
+
282
+ :return: *Response* containing the user reference identification and the token, or *BAD REQUEST*
283
+ """
284
+ # log the request
285
+ if __IAM_LOGGER:
286
+ __IAM_LOGGER.debug(msg=_log_init(request=request))
287
+
288
+ # obtain the user's identification
289
+ args: dict[str, Any] = request.args
290
+ user_id: str = args.get("user-id") or args.get("login")
291
+
292
+ errors: list[str] = []
293
+ token: str | None = None
294
+ if user_id:
295
+ with _iam_lock:
296
+ # retrieve the IAM server
297
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
298
+ errors=errors,
299
+ logger=__IAM_LOGGER)
300
+ if iam_server:
301
+ # retrieve the token
302
+ errors: list[str] = []
303
+ token: str = action_token(iam_server=iam_server,
304
+ args=args,
305
+ errors=errors,
306
+ logger=__IAM_LOGGER)
307
+ else:
308
+ msg: str = "User identification not provided"
309
+ errors.append(msg)
310
+ if __IAM_LOGGER:
311
+ __IAM_LOGGER.error(msg=msg)
312
+
313
+ result: Response
314
+ if errors:
315
+ result = Response(response="; ".join(errors),
316
+ status=400)
317
+ else:
318
+ result = jsonify({"user-id": user_id,
319
+ "access-token": token})
320
+ if __IAM_LOGGER:
321
+ # log the response (the returned data is not logged, as it contains the token)
322
+ __IAM_LOGGER.debug(msg=f"Response {result}")
323
+
324
+ return result
325
+
326
+
327
+ # @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_ENDPOINT_EXCHANGE
328
+ # methods=["POST"])
329
+ def service_exchange() -> Response:
330
+ """
331
+ Entry point for requesting the *IAM* server to exchange the token.
332
+
333
+ This is currently limited to the *KEYCLOAK* server. The token itself is stored in *KEYCLOAK*'s registry.
334
+ The expected request parameters are:
335
+ - user-id: identification for the reference user (alias: 'login')
336
+ - access-token: the token to be exchanged
337
+
338
+ If the exchange is successful, the token data is stored in the *IAM* server's registry, and returned.
339
+ Otherwise, *errors* will contain the appropriate error message.
340
+
341
+ On success, the typical *Response* returned will contain the following attributes:
342
+ {
343
+ "token_type": "Bearer",
344
+ "access_token": <str>,
345
+ "expires_in": <number-of-seconds>,
346
+ "refresh_token": <str>,
347
+ "refesh_expires_in": <number-of-seconds>
348
+ }
349
+
350
+ :return: *Response* containing the token data, or *BAD REQUEST*
351
+ """
352
+ # log the request
353
+ if __IAM_LOGGER:
354
+ __IAM_LOGGER.debug(msg=_log_init(request=request))
355
+
356
+ errors: list[str] = []
357
+ with _iam_lock:
358
+ # retrieve the IAM server (currently, only 'IAM_KEYCLOAK' is supported)
359
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
360
+ errors=errors,
361
+ logger=__IAM_LOGGER)
362
+ # exchange the token
363
+ token_data: dict[str, Any] | None = None
364
+ if iam_server:
365
+ errors: list[str] = []
366
+ token_data = action_exchange(iam_server=iam_server,
367
+ args=request.args,
368
+ errors=errors,
369
+ logger=__IAM_LOGGER)
370
+ result: Response
371
+ if errors:
372
+ result = Response(response="; ".join(errors),
373
+ status=400)
374
+ else:
375
+ result = jsonify(token_data)
376
+
377
+ # log the response
378
+ if __IAM_LOGGER:
379
+ __IAM_LOGGER.debug(msg=f"Response {result}, {result.get_data(as_text=True)}")
380
+
381
+ return result
382
+
383
+
384
+ def _log_init(request: Request) -> str:
385
+ """
386
+ Build the messages for logging the request entry.
387
+
388
+ :param request: the Request object
389
+ :return: the log message
390
+ """
391
+
392
+ params: str = json.dumps(obj=request.args,
393
+ ensure_ascii=False)
394
+ return f"Request {request.method}:{request.path}, params {params}"
@@ -1,10 +1,30 @@
1
+ import json
1
2
  import requests
2
3
  import sys
3
4
  from base64 import b64encode
4
5
  from datetime import datetime
6
+ from enum import StrEnum
5
7
  from logging import Logger
6
8
  from pypomes_core import TZ_LOCAL, exc_format
7
- from typing import Any
9
+ from threading import Lock
10
+ from typing import Any, Final
11
+
12
+
13
+ class ProviderParam(StrEnum):
14
+ """
15
+ Parameters for configuring a *JWT* token provider.
16
+ """
17
+ URL = "url"
18
+ USER = "user"
19
+ PWD = "pwd"
20
+ CUSTOM_AUTH = "custom-auth"
21
+ HEADER_DATA = "headers-data"
22
+ BODY_DATA = "body-data"
23
+ ACCESS_TOKEN = "access-token"
24
+ ACCESS_EXPIRATION = "access-expiration"
25
+ REFRESH_TOKEN = "refresh-token"
26
+ REFRESH_EXPIRATION = "refresh-expiration"
27
+
8
28
 
9
29
  # structure:
10
30
  # {
@@ -12,14 +32,20 @@ from typing import Any
12
32
  # "url": <strl>,
13
33
  # "user": <str>,
14
34
  # "pwd": <str>,
15
- # "basic-auth": <bool>,
35
+ # "custom-auth": <bool>,
16
36
  # "headers-data": <dict[str, str]>,
17
37
  # "body-data": <dict[str, str],
18
- # "token": <str>,
19
- # "expiration": <timestamp>
38
+ # "access-token": <str>,
39
+ # "access-expiration": <timestamp>,
40
+ # "refresh-token": <str>,
41
+ # "refresh-expiration": <timestamp>
20
42
  # }
21
43
  # }
22
- _provider_registry: dict[str, dict[str, Any]] = {}
44
+ _provider_registry: Final[dict[str, dict[str, Any]]] = {}
45
+
46
+ # the lock protecting the data in '_provider_registry'
47
+ # (because it is 'Final' and set at declaration time, it can be accessed through simple imports)
48
+ _provider_lock: Final[Lock] = Lock()
23
49
 
24
50
 
25
51
  def provider_register(provider_id: str,
@@ -48,18 +74,21 @@ def provider_register(provider_id: str,
48
74
  :param headers_data: optional key-value pairs to be added to the request headers
49
75
  :param body_data: optional key-value pairs to be added to the request body
50
76
  """
51
- global _provider_registry # noqa: PLW0602
52
-
53
- _provider_registry[provider_id] = {
54
- "url": auth_url,
55
- "user": auth_user,
56
- "pwd": auth_pwd,
57
- "custom-auth": custom_auth,
58
- "headers-data": headers_data,
59
- "body-data": body_data,
60
- "token": None,
61
- "expiration": datetime.now(tz=TZ_LOCAL).timestamp()
62
- }
77
+ global _provider_registry
78
+
79
+ with _provider_lock:
80
+ _provider_registry[provider_id] = {
81
+ ProviderParam.URL: auth_url,
82
+ ProviderParam.USER: auth_user,
83
+ ProviderParam.PWD: auth_pwd,
84
+ ProviderParam.CUSTOM_AUTH: custom_auth,
85
+ ProviderParam.HEADER_DATA: headers_data,
86
+ ProviderParam.BODY_DATA: body_data,
87
+ ProviderParam.ACCESS_TOKEN: None,
88
+ ProviderParam.ACCESS_EXPIRATION: 0,
89
+ ProviderParam.REFRESH_TOKEN: None,
90
+ ProviderParam.REFRESH_EXPIRATION: 0
91
+ }
63
92
 
64
93
 
65
94
  def provider_get_token(provider_id: str,
@@ -77,63 +106,137 @@ def provider_get_token(provider_id: str,
77
106
  # initialize the return variable
78
107
  result: str | None = None
79
108
 
80
- err_msg: str | None = None
81
- provider: dict[str, Any] = _provider_registry.get(provider_id)
82
- if provider:
83
- now: float = datetime.now(tz=TZ_LOCAL).timestamp()
84
- if now > provider.get("expiration"):
85
- user: str = provider.get("user")
86
- pwd: str = provider.get("pwd")
87
- headers_data: dict[str, str] = provider.get("headers-data") or {}
88
- body_data: dict[str, str] = provider.get("body-data") or {}
89
- custom_auth: tuple[str, str] = provider.get("custom-auth")
90
- if custom_auth:
91
- body_data[custom_auth[0]] = user
92
- body_data[custom_auth[1]] = pwd
109
+ with _provider_lock:
110
+ provider: dict[str, Any] = _provider_registry.get(provider_id)
111
+ if provider:
112
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
113
+ if now < provider.get(ProviderParam.ACCESS_EXPIRATION):
114
+ # retrieve the stored access token
115
+ result = provider.get(ProviderParam.ACCESS_TOKEN)
93
116
  else:
94
- enc_bytes: bytes = b64encode(f"{user}:{pwd}".encode())
95
- headers_data["Authorization"] = f"Basic {enc_bytes.decode()}"
96
- url: str = provider.get("url")
97
- try:
98
- # typical return on a token request:
99
- # {
100
- # "token_type": "Bearer",
101
- # "access_token": <str>,
102
- # "expires_in": <number-of-seconds>,
103
- # optional data:
104
- # "refresh_token": <str>,
105
- # "refresh_expires_in": <number-of-seconds>
106
- # }
107
- response: requests.Response = requests.post(url=url,
108
- data=body_data,
109
- headers=headers_data,
110
- timeout=None)
111
- if response.status_code < 200 or response.status_code >= 300:
112
- # request resulted in error, report the problem
113
- err_msg = (f"POST '{url}': failed, "
114
- f"status {response.status_code}, reason '{response.reason}'")
115
- else:
116
- reply: dict[str, Any] = response.json()
117
- provider["token"] = reply.get("access_token")
118
- provider["expiration"] = now + int(reply.get("expires_in"))
119
- if logger:
120
- logger.debug(msg=f"POST '{url}': status {response.status_code}")
121
- except Exception as e:
122
- # the operation raised an exception
123
- err_msg = exc_format(exc=e,
124
- exc_info=sys.exc_info())
125
- err_msg = f"POST '{url}': error, '{err_msg}'"
126
- else:
127
- err_msg: str = f"Provider '{provider_id}' not registered"
128
-
129
- if err_msg:
130
- if isinstance(errors, list):
131
- errors.append(err_msg)
132
- if logger:
133
- logger.error(msg=err_msg)
134
- else:
135
- result = provider.get("token")
117
+ # access token has expired
118
+ header_data: dict[str, str] | None = None
119
+ body_data: dict[str, str] | None = None
120
+ url: str = provider.get(ProviderParam.URL)
121
+ refresh_token: str = provider.get(ProviderParam.REFRESH_TOKEN)
122
+ if refresh_token:
123
+ # refresh token exists
124
+ refresh_expiration: int = provider.get(ProviderParam.REFRESH_EXPIRATION)
125
+ if now < refresh_expiration:
126
+ # refresh token has not expired
127
+ header_data: dict[str, str] = {
128
+ "Content-Type": "application/json"
129
+ }
130
+ body_data: dict[str, str] = {
131
+ "grant_type": "refresh_token",
132
+ "refresh_token": refresh_token
133
+ }
134
+ if not body_data:
135
+ # refresh token does not exist or has expired
136
+ user: str = provider.get(ProviderParam.USER)
137
+ pwd: str = provider.get(ProviderParam.PWD)
138
+ headers_data: dict[str, str] = provider.get(ProviderParam.HEADER_DATA) or {}
139
+ body_data: dict[str, str] = provider.get(ProviderParam.BODY_DATA) or {}
140
+ custom_auth: tuple[str, str] = provider.get(ProviderParam.CUSTOM_AUTH)
141
+ if custom_auth:
142
+ body_data[custom_auth[0]] = user
143
+ body_data[custom_auth[1]] = pwd
144
+ else:
145
+ enc_bytes: bytes = b64encode(f"{user}:{pwd}".encode())
146
+ headers_data["Authorization"] = f"Basic {enc_bytes.decode()}"
147
+
148
+ # obtain the token
149
+ token_data: dict[str, Any] = __post_for_token(url=url,
150
+ header_data=header_data,
151
+ body_data=body_data,
152
+ errors=errors,
153
+ logger=logger)
154
+ if token_data:
155
+ result = token_data.get("access_token")
156
+ provider[ProviderParam.ACCESS_TOKEN] = result
157
+ provider[ProviderParam.ACCESS_EXPIRATION] = now + token_data.get("expires_in")
158
+ refresh_token = token_data.get("refresh_token")
159
+ if refresh_token:
160
+ provider[ProviderParam.REFRESH_TOKEN] = refresh_token
161
+ refresh_exp: int = token_data.get("refresh_expires_in")
162
+ provider[ProviderParam.REFRESH_EXPIRATION] = (now + refresh_exp) \
163
+ if refresh_exp else sys.maxsize
164
+
165
+ elif logger or isinstance(errors, list):
166
+ msg: str = f"Unknown provider '{provider_id}'"
167
+ if logger:
168
+ logger.error(msg=msg)
169
+ if isinstance(errors, list):
170
+ errors.append(msg)
136
171
 
137
172
  return result
138
173
 
139
174
 
175
+ def __post_for_token(url: str,
176
+ header_data: dict[str, str],
177
+ body_data: dict[str, Any],
178
+ errors: list[str] | None,
179
+ logger: Logger | None) -> dict[str, Any] | None:
180
+ """
181
+ Send a *POST* request to *url* and return the token data obtained.
182
+
183
+ Token acquisition and token refresh are the two types of requests contemplated herein.
184
+ For the former, *header_data* and *body_data* will have contents customized to the specific provider,
185
+ whereas the latter's *body_data* will contain these two attributes:
186
+ - "grant_type": "refresh_token"
187
+ - "refresh_token": <current-refresh-token>
188
+
189
+ The typical data set returned contains the following attributes:
190
+ {
191
+ "token_type": "Bearer",
192
+ "access_token": <str>,
193
+ "expires_in": <number-of-seconds>,
194
+ "refresh_token": <str>,
195
+ "refesh_expires_in": <number-of-seconds>
196
+ }
197
+
198
+ :param url: the target URL
199
+ :param header_data: the data to send in the header of the request
200
+ :param body_data: the data to send in the body of the request
201
+ :param errors: incidental errors
202
+ :param logger: optional logger
203
+ :return: the token data, or *None* if error
204
+ """
205
+ # initialize the return variable
206
+ result: dict[str, Any] | None = None
207
+
208
+ # log the POST
209
+ if logger:
210
+ logger.debug(msg=f"POST {url}, {json.dumps(obj=body_data,
211
+ ensure_ascii=False)}")
212
+ try:
213
+ response: requests.Response = requests.post(url=url,
214
+ data=body_data,
215
+ headers=header_data,
216
+ timeout=None)
217
+ if response.status_code == 200:
218
+ # request succeeded
219
+ result = response.json()
220
+ if logger:
221
+ logger.debug(msg=f"POST success, status {response.status_code}")
222
+ else:
223
+ # request failed, report the problem
224
+ msg: str = (f"POST failure, "
225
+ f"status {response.status_code}, reason {response.reason}")
226
+ if hasattr(response, "content") and response.content:
227
+ msg += f", content '{response.content}'"
228
+ if logger:
229
+ logger.error(msg=msg)
230
+ if isinstance(errors, list):
231
+ errors.append(msg)
232
+ except Exception as e:
233
+ # the operation raised an exception
234
+ err_msg = exc_format(exc=e,
235
+ exc_info=sys.exc_info())
236
+ msg: str = f"POST error, {err_msg}"
237
+ if logger:
238
+ logger.debug(msg=msg)
239
+ if isinstance(errors, list):
240
+ errors.append(msg)
241
+
242
+ return result