pypomes-iam 0.2.3__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.
- pypomes_iam/__init__.py +20 -8
- pypomes_iam/iam_actions.py +878 -0
- pypomes_iam/iam_common.py +388 -0
- pypomes_iam/iam_pomes.py +137 -157
- pypomes_iam/iam_services.py +394 -0
- pypomes_iam/provider_pomes.py +175 -72
- pypomes_iam/token_pomes.py +63 -8
- {pypomes_iam-0.2.3.dist-info → pypomes_iam-0.7.0.dist-info}/METADATA +1 -2
- pypomes_iam-0.7.0.dist-info/RECORD +11 -0
- pypomes_iam/common_pomes.py +0 -397
- pypomes_iam/jusbr_pomes.py +0 -167
- pypomes_iam/keycloak_pomes.py +0 -170
- pypomes_iam-0.2.3.dist-info/RECORD +0 -11
- {pypomes_iam-0.2.3.dist-info → pypomes_iam-0.7.0.dist-info}/WHEEL +0 -0
- {pypomes_iam-0.2.3.dist-info → pypomes_iam-0.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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}"
|
pypomes_iam/provider_pomes.py
CHANGED
|
@@ -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
|
|
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
|
-
# "
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|