pypomes-iam 0.3.4__py3-none-any.whl → 0.3.6__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/iam_pomes.py CHANGED
@@ -1,18 +1,19 @@
1
+ import json
2
+ import requests
1
3
  import secrets
2
4
  import string
3
5
  import sys
4
- from cachetools import Cache
5
6
  from datetime import datetime
6
7
  from logging import Logger
7
- from pypomes_core import TZ_LOCAL
8
+ from pypomes_core import TZ_LOCAL, exc_format
8
9
  from typing import Any
9
10
 
10
11
  from .iam_common import (
11
- IamServer,
12
- _register_logger, _post_for_token,
13
- _get_iam_cache, _get_iam_registry,
12
+ IamServer, _iam_lock,
13
+ _register_logger, _get_iam_users, _get_iam_registry,
14
14
  _get_login_timeout, _get_user_data, _get_public_key
15
15
  )
16
+ from .token_pomes import token_validate
16
17
 
17
18
 
18
19
  def register_logger(logger: Logger) -> None:
@@ -46,30 +47,34 @@ def user_login(iam_server: IamServer,
46
47
  # build the user data
47
48
  # ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
48
49
  oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
49
- user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
50
- user_id=oauth_state,
51
- errors=errors,
52
- logger=logger)
53
- if user_data:
54
- user_data["login-id"] = user_id
55
- timeout: int = _get_login_timeout(iam_server=iam_server,
56
- errors=errors,
57
- logger=logger)
58
- if not errors:
59
- user_data["login-expiration"] = int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout if timeout else None
60
- redirect_uri: str = args.get("redirect-uri")
61
-
62
- # build the login url
63
- registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
64
- errors=errors,
65
- logger=logger)
66
- if registry:
67
- registry["redirect-uri"] = redirect_uri
68
- result = {"login-url": (f"{registry["base-url"]}/protocol/openid-connect/auth"
69
- f"?response_type=code&scope=openid"
70
- f"&client_id={registry["client-id"]}"
71
- f"&redirect_uri={redirect_uri}"
72
- f"&state={oauth_state}")}
50
+
51
+ with _iam_lock:
52
+ # retrieve the user data from the IAM server's registry
53
+ user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
54
+ user_id=oauth_state,
55
+ errors=errors,
56
+ logger=logger)
57
+ if user_data:
58
+ user_data["login-id"] = user_id
59
+ timeout: int = _get_login_timeout(iam_server=iam_server,
60
+ errors=errors,
61
+ logger=logger)
62
+ if not errors:
63
+ user_data["login-expiration"] = int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout \
64
+ if timeout else None
65
+ redirect_uri: str = args.get("redirect-uri")
66
+
67
+ # build the login url
68
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
69
+ errors=errors,
70
+ logger=logger)
71
+ if registry:
72
+ registry["redirect-uri"] = redirect_uri
73
+ result = {"login-url": (f"{registry["base-url"]}/protocol/openid-connect/auth"
74
+ f"?response_type=code&scope=openid"
75
+ f"&client_id={registry["client-id"]}"
76
+ f"&redirect_uri={redirect_uri}"
77
+ f"&state={oauth_state}")}
73
78
  return result
74
79
 
75
80
 
@@ -92,12 +97,11 @@ def user_logout(iam_server: IamServer,
92
97
  user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
93
98
 
94
99
  if user_id:
95
- # retrieve the IAM server's cache storage
96
- cache: Cache = _get_iam_cache(iam_server=iam_server,
97
- errors=errors,
98
- logger=logger)
99
- if cache:
100
- users: dict[str, dict[str, Any]] = cache.get("users") or {}
100
+ with _iam_lock:
101
+ # retrieve the data for all users in the IAM server's registry
102
+ users: dict[str, dict[str, Any]] = _get_iam_users(iam_server=iam_server,
103
+ errors=errors,
104
+ logger=logger) or {}
101
105
  if user_id in users:
102
106
  users.pop(user_id)
103
107
  if logger:
@@ -111,7 +115,7 @@ def user_token(iam_server: IamServer,
111
115
  """
112
116
  Retrieve the authentication token for the user, from *iam_server*.
113
117
 
114
- The user is identified by the attribute *user-id*, *user_id*, or "login", provided in *args*.
118
+ The user is identified by the attribute *user-id*, *user_id*, or *login*, provided in *args*.
115
119
 
116
120
  :param iam_server: the reference registered *IAM* server
117
121
  :param args: the arguments passed when requesting the service
@@ -127,56 +131,58 @@ def user_token(iam_server: IamServer,
127
131
 
128
132
  err_msg: str | None = None
129
133
  if user_id:
130
- user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
131
- user_id=user_id,
132
- errors=errors,
133
- logger=logger)
134
- token: str = user_data["access-token"] if user_data else None
135
- if token:
136
- access_expiration: int = user_data.get("access-expiration")
137
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
138
- if now < access_expiration:
139
- result = token
140
- else:
141
- # access token has expired
142
- refresh_token: str = user_data["refresh-token"]
143
- if refresh_token:
144
- refresh_expiration = user_data["refresh-expiration"]
145
- if now < refresh_expiration:
146
- body_data: dict[str, str] = {
147
- "grant_type": "refresh_token",
148
- "refresh_token": refresh_token
149
- }
150
- token_data: dict[str, Any] = _post_for_token(iam_server=iam_server,
151
- body_data=body_data,
152
- errors=errors,
153
- logger=logger)
154
- if token_data:
155
- result = token_data.get("access_token")
156
- user_data["access-token"] = result
157
- # keep current refresh token if a new one is not provided
158
- user_data["refresh-token"] = (token_data.get("refresh_token") or
159
- body_data.get("refresh_token"))
160
- user_data["access-expiration"] = now + token_data.get("expires_in")
161
- refresh_expiration: int = user_data.get("refresh_expires_in")
162
- user_data["refresh-expiration"] = (now + refresh_expiration) \
163
- if refresh_expiration else sys.maxsize
134
+ with _iam_lock:
135
+ # retrieve the user data in the IAM server's registry
136
+ user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
137
+ user_id=user_id,
138
+ errors=errors,
139
+ logger=logger)
140
+ token: str = user_data["access-token"] if user_data else None
141
+ if token:
142
+ access_expiration: int = user_data.get("access-expiration")
143
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
144
+ if now < access_expiration:
145
+ result = token
146
+ else:
147
+ # access token has expired
148
+ refresh_token: str = user_data["refresh-token"]
149
+ if refresh_token:
150
+ refresh_expiration = user_data["refresh-expiration"]
151
+ if now < refresh_expiration:
152
+ body_data: dict[str, str] = {
153
+ "grant_type": "refresh_token",
154
+ "refresh_token": refresh_token
155
+ }
156
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
157
+ token_data: dict[str, Any] = __post_for_token(iam_server=iam_server,
158
+ body_data=body_data,
159
+ errors=errors,
160
+ logger=logger)
161
+ # validate and store the token data
162
+ if token_data:
163
+ token_info: tuple[str, str] = __validate_and_store(iam_server=iam_server,
164
+ user_data=user_data,
165
+ token_data=token_data,
166
+ now=now,
167
+ errors=errors,
168
+ logger=logger)
169
+ result = token_info[1]
170
+ else:
171
+ # refresh token is no longer valid
172
+ user_data["refresh-token"] = None
164
173
  else:
165
- # refresh token is no longer valid
166
- user_data["refresh-token"] = None
174
+ # refresh token has expired
175
+ err_msg = "Access and refresh tokens expired"
176
+ if logger:
177
+ logger.error(msg=err_msg)
167
178
  else:
168
- # refresh token has expired
169
- err_msg = "Access and refresh tokens expired"
179
+ err_msg = "Access token expired, no refresh token available"
170
180
  if logger:
171
181
  logger.error(msg=err_msg)
172
- else:
173
- err_msg = "Access token expired, no refresh token available"
174
- if logger:
175
- logger.error(msg=err_msg)
176
- else:
177
- err_msg = f"User '{user_id}' not authenticated"
178
- if logger:
179
- logger.error(msg=err_msg)
182
+ else:
183
+ err_msg = f"User '{user_id}' not authenticated"
184
+ if logger:
185
+ logger.error(msg=err_msg)
180
186
  else:
181
187
  err_msg = "User identification not provided"
182
188
  if logger:
@@ -193,7 +199,11 @@ def login_callback(iam_server: IamServer,
193
199
  errors: list[str] = None,
194
200
  logger: Logger = None) -> tuple[str, str] | None:
195
201
  """
196
- Entry point for the callback from *iam_server* via the front-end application, on authentication operation.
202
+ Entry point for the callback from *iam_server* via the front-end application, on authentication operations.
203
+
204
+ The relevant arguments received are:
205
+ - *state*: used to enhance security during the authorization process, typically to provide *CSRF* protection
206
+ - *code*: the temporary authorization code, to be exchanged for the token
197
207
 
198
208
  :param iam_server: the reference registered *IAM* server
199
209
  :param args: the arguments passed when requesting the service
@@ -201,20 +211,16 @@ def login_callback(iam_server: IamServer,
201
211
  :param logger: optional logger
202
212
  :return: a tuple containing the reference user identification and the token obtained, or *None* if error
203
213
  """
204
- from .token_pomes import token_validate
205
-
206
214
  # initialize the return variable
207
215
  result: tuple[str, str] | None = None
208
216
 
209
- # retrieve the users authentication data
210
- registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
211
- errors=errors,
212
- logger=logger)
213
- cache: Cache = registry["cache"] if registry else None
214
- if cache:
215
- users: dict[str, dict[str, Any]] = cache.get("users")
216
-
217
- # validate the OAuth2 state
217
+ with _iam_lock:
218
+ # retrieve the IAM server's registry and the data for all users therein
219
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
220
+ errors=errors,
221
+ logger=logger)
222
+ users: dict[str, dict[str, Any]] = (registry["cache"]["users"] or {}) if registry else {}
223
+ # retrieve the OAuth2 state
218
224
  oauth_state: str = args.get("state")
219
225
  user_data: dict[str, Any] | None = None
220
226
  if oauth_state:
@@ -223,7 +229,7 @@ def login_callback(iam_server: IamServer,
223
229
  user_data = data
224
230
  break
225
231
 
226
- # exchange 'code' for the token
232
+ # exchange 'code' received for the token
227
233
  if user_data:
228
234
  expiration: int = user_data["login-expiration"] or sys.maxsize
229
235
  if int(datetime.now(tz=TZ_LOCAL).timestamp()) > expiration:
@@ -237,35 +243,18 @@ def login_callback(iam_server: IamServer,
237
243
  "redirect_uri": registry["redirect-uri"]
238
244
  }
239
245
  now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
240
- token_data: dict[str, Any] = _post_for_token(iam_server=iam_server,
241
- body_data=body_data,
242
- errors=errors,
243
- logger=logger)
244
- # process the token data
246
+ token_data: dict[str, Any] = __post_for_token(iam_server=iam_server,
247
+ body_data=body_data,
248
+ errors=errors,
249
+ logger=logger)
250
+ # validate and store the token data
245
251
  if token_data:
246
- token: str = token_data.get("access_token")
247
- user_data["access-token"] = token
248
- # keep current refresh token if a new one is not provided
249
- user_data["refresh-token"] = token_data.get("refresh_token") or body_data.get("refresh_token")
250
- user_data["access-expiration"] = now + token_data.get("expires_in")
251
- refresh_exp: int = user_data.get("refresh_expires_in")
252
- user_data["refresh-expiration"] = (now + refresh_exp) if refresh_exp else sys.maxsize
253
- public_key: str = _get_public_key(iam_server=iam_server,
254
- errors=errors,
255
- logger=logger)
256
- if public_key:
257
- recipient_attr = registry["recipient_attr"]
258
- login_id = user_data.pop("login-id", None)
259
- token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
260
- issuer=registry["base-url"],
261
- recipient_id=login_id,
262
- recipient_attr=recipient_attr,
263
- public_key=public_key,
264
- errors=errors,
265
- logger=logger)
266
- if token_claims:
267
- token_user: str = token_claims["payload"].get(recipient_attr)
268
- result = (token_user, token)
252
+ result = __validate_and_store(iam_server=iam_server,
253
+ user_data=user_data,
254
+ token_data=token_data,
255
+ now=now,
256
+ errors=errors,
257
+ logger=logger)
269
258
  else:
270
259
  msg: str = "Unknown state received"
271
260
  if logger:
@@ -281,7 +270,20 @@ def token_exchange(iam_server: IamServer,
281
270
  errors: list[str] = None,
282
271
  logger: Logger = None) -> dict[str, Any]:
283
272
  """
284
- Requst *iam_server* to issue a token in exchange for the token obtained from another *IAM* server.
273
+ Request *iam_server* to issue a token in exchange for the token obtained from another *IAM* server.
274
+
275
+ The expected parameters in *args* are:
276
+ - client-id: identification for the reference user (aliases: 'client_id', 'login')
277
+ - token: the token to be exchanged
278
+
279
+ The typical data set returned contains the following attributes:
280
+ {
281
+ "token_type": "Bearer",
282
+ "access_token": <str>,
283
+ "expires_in": <number-of-seconds>,
284
+ "refresh_token": <str>,
285
+ "refesh_expires_in": <number-of-seconds>
286
+ }
285
287
 
286
288
  :param iam_server: the reference registered *IAM* server
287
289
  :param args: the arguments passed when requesting the service
@@ -295,32 +297,213 @@ def token_exchange(iam_server: IamServer,
295
297
  # obtain the user's identification
296
298
  user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
297
299
 
298
- # retrieve the token to be exchanges
300
+ # obtain the token to be exchanges
299
301
  token: str = args.get("token")
300
302
 
301
303
  if user_id and token:
302
304
  # HAZARD: only 'IAM_KEYCLOAK' is currently supported
303
- registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
304
- errors=errors,
305
- logger=logger)
306
- if registry:
307
- body_data: dict[str, str] = {
308
- "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
309
- "subject_token": token,
310
- "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
311
- "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
312
- "audience": registry["client-id"],
313
- "subject_issuer": "oidc"
314
- }
315
- result = _post_for_token(iam_server=IamServer.IAM_KEYCLOAK,
316
- body_data=body_data,
317
- errors=errors,
318
- logger=logger)
305
+ with _iam_lock:
306
+ # retrieve the IAM server's registry
307
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
308
+ errors=errors,
309
+ logger=logger)
310
+ if registry:
311
+ body_data: dict[str, str] = {
312
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
313
+ "subject_token": token,
314
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
315
+ "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
316
+ "audience": registry["client-id"],
317
+ "subject_issuer": "oidc"
318
+ }
319
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
320
+ token_data: dict[str, Any] = __post_for_token(iam_server=IamServer.IAM_KEYCLOAK,
321
+ body_data=body_data,
322
+ errors=errors,
323
+ logger=logger)
324
+ # validate and store the token data
325
+ if token_data:
326
+ user_data: dict[str, Any] = {}
327
+ result = __validate_and_store(iam_server=iam_server,
328
+ user_data=user_data,
329
+ token_data=token_data,
330
+ now=now,
331
+ errors=errors,
332
+ logger=logger)
319
333
  else:
320
- msg: str = "User identification and token must be provided"
334
+ msg: str = "User identification or token not provided"
321
335
  if logger:
322
336
  logger.error(msg=msg)
323
337
  if isinstance(errors, list):
324
338
  errors.append(msg)
325
339
 
326
340
  return result
341
+
342
+
343
+ def __post_for_token(iam_server: IamServer,
344
+ body_data: dict[str, Any],
345
+ errors: list[str] | None,
346
+ logger: Logger | None) -> dict[str, Any] | None:
347
+ """
348
+ Send a POST request to obtain the authentication token data, and return the data received.
349
+
350
+ For token acquisition, *body_data* will have the attributes:
351
+ - "grant_type": "authorization_code"
352
+ - "code": <16-character-random-code>
353
+ - "redirect_uri": <redirect-uri>
354
+
355
+ For token refresh, *body_data* will have the attributes:
356
+ - "grant_type": "refresh_token"
357
+ - "refresh_token": <current-refresh-token>
358
+
359
+ For token exchange, *body_data* will have the attributes:
360
+ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
361
+ - "subject_token": <token-to-be-exchanged>,
362
+ - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
363
+ - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
364
+ - "audience": <client-id>,
365
+ - "subject_issuer": "oidc"
366
+
367
+ These attributes are then added to *body_data*:
368
+ - "client_id": <client-id>,
369
+ - "client_secret": <client-secret>,
370
+
371
+ If the operation is successful, the token data is stored in the *IAM* server's registry, and returned.
372
+ Otherwise, *errors* will contain the appropriate error message.
373
+
374
+ The typical data set returned contains the following attributes:
375
+ {
376
+ "token_type": "Bearer",
377
+ "access_token": <str>,
378
+ "expires_in": <number-of-seconds>,
379
+ "refresh_token": <str>,
380
+ "refesh_expires_in": <number-of-seconds>
381
+ }
382
+
383
+ :param iam_server: the reference registered *IAM* server
384
+ :param body_data: the data to send in the body of the request
385
+ :param errors: incidental errors
386
+ :param logger: optional logger
387
+ :return: the token data, or *None* if error
388
+ """
389
+ # initialize the return variable
390
+ result: dict[str, Any] | None = None
391
+
392
+ err_msg: str | None = None
393
+ with _iam_lock:
394
+ # retrieve the IAM server's registry
395
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
396
+ errors=errors,
397
+ logger=logger)
398
+ if registry:
399
+ # complete the data to send in body of request
400
+ body_data["client_id"] = registry["client-id"]
401
+ client_secret: str = registry["client-secret"]
402
+ if client_secret:
403
+ body_data["client_secret"] = client_secret
404
+
405
+ # obtain the token
406
+ url: str = registry["base-url"] + "/protocol/openid-connect/token"
407
+ if logger:
408
+ logger.debug(msg=f"POST '{url}', data {json.dumps(obj=body_data,
409
+ ensure_ascii=False)}")
410
+ try:
411
+ # typical return on a token request:
412
+ # {
413
+ # "token_type": "Bearer",
414
+ # "access_token": <str>,
415
+ # "expires_in": <number-of-seconds>,
416
+ # "refresh_token": <str>,
417
+ # "refesh_expires_in": <number-of-seconds>
418
+ # }
419
+ response: requests.Response = requests.post(url=url,
420
+ data=body_data)
421
+ if response.status_code == 200:
422
+ # request succeeded
423
+ if logger:
424
+ logger.debug(msg=f"POST success, status {response.status_code}")
425
+ result = response.json()
426
+ else:
427
+ # request resulted in error
428
+ err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
429
+ if hasattr(response, "content") and response.content:
430
+ err_msg += f", content '{response.content}'"
431
+ if logger:
432
+ logger.error(msg=err_msg)
433
+ except Exception as e:
434
+ # the operation raised an exception
435
+ err_msg = exc_format(exc=e,
436
+ exc_info=sys.exc_info())
437
+ if logger:
438
+ logger.error(msg=err_msg)
439
+
440
+ if err_msg and isinstance(errors, list):
441
+ errors.append(err_msg)
442
+
443
+ return result
444
+
445
+
446
+ def __validate_and_store(iam_server: IamServer,
447
+ user_data: dict[str, Any],
448
+ token_data: dict[str, Any],
449
+ now: int,
450
+ errors: list[str] | None,
451
+ logger: Logger) -> tuple[str, str] | None:
452
+ """
453
+ Validate and store the token data.
454
+
455
+ The typical *token_data* contains the following attributes:
456
+ {
457
+ "token_type": "Bearer",
458
+ "access_token": <str>,
459
+ "expires_in": <number-of-seconds>,
460
+ "refresh_token": <str>,
461
+ "refesh_expires_in": <number-of-seconds>
462
+ }
463
+
464
+ :param iam_server: the reference registered *IAM* server
465
+ :param user_data: the aurthentication data kepth in *iam_server*'s registry
466
+ :param token_data: the token data
467
+ :param errors: incidental errors
468
+ :param logger: optional logger
469
+ :return: tuple containing the user identification and the validated and stored token, or *None* if error
470
+ """
471
+ # initialize the return variable
472
+ result: tuple[str, str] | None = None
473
+
474
+ with _iam_lock:
475
+ # retrieve the IAM server's registry
476
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
477
+ errors=errors,
478
+ logger=logger)
479
+ if registry:
480
+ token: str = token_data.get("access_token")
481
+ user_data["access-token"] = token
482
+ # keep current refresh token if a new one is not provided
483
+ if token_data.get("refresh_token"):
484
+ user_data["refresh-token"] = token_data.get("refresh_token")
485
+ user_data["access-expiration"] = now + token_data.get("expires_in")
486
+ refresh_exp: int = user_data.get("refresh_expires_in")
487
+ user_data["refresh-expiration"] = (now + refresh_exp) if refresh_exp else sys.maxsize
488
+ public_key: str = _get_public_key(iam_server=iam_server,
489
+ errors=errors,
490
+ logger=logger)
491
+ if public_key:
492
+ recipient_attr = registry["recipient_attr"]
493
+ login_id = user_data.pop("login-id", None)
494
+ claims: dict[str, dict[str, Any]] = token_validate(token=token,
495
+ issuer=registry["base-url"],
496
+ recipient_id=login_id,
497
+ recipient_attr=recipient_attr,
498
+ public_key=public_key,
499
+ errors=errors,
500
+ logger=logger)
501
+ if claims:
502
+ users: dict[str, dict[str, Any]] = _get_iam_users(iam_server=iam_server,
503
+ errors=errors,
504
+ logger=logger)
505
+ if users:
506
+ user_id: str = login_id if login_id else claims["payload"][recipient_attr]
507
+ users[user_id] = user_data
508
+ result = (user_id, token)
509
+ return result