pypomes-iam 0.7.4__py3-none-any.whl → 0.8.3__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 CHANGED
@@ -1,37 +1,45 @@
1
1
  from .iam_actions import (
2
- action_callback, action_exchange,
3
- action_login, action_logout, action_token
2
+ iam_callback, iam_exchange,
3
+ iam_login, iam_logout, iam_get_token, iam_userinfo
4
4
  )
5
5
  from .iam_common import (
6
6
  IamServer, IamParam
7
7
  )
8
8
  from .iam_pomes import (
9
- iam_setup, iam_get_env_parameters, iam_get_token
9
+ iam_setup_server, iam_setup_endpoints
10
10
  )
11
11
  from .iam_services import (
12
- jwt_required, logger_register
12
+ jwt_required, iam_setup_logger,
13
+ service_setup_server, service_login, service_logout,
14
+ service_get_token, service_userinfo, service_callback,
15
+ service_exchange, service_callback_exchange
13
16
  )
14
17
  from .provider_pomes import (
15
- provider_register, provider_get_token
18
+ service_get_token, provider_get_token,
19
+ provider_setup_endpoint, provider_setup_logger, provider_setup_server
16
20
  )
17
21
  from .token_pomes import (
18
- token_validate
22
+ token_get_claims, token_get_values, token_validate
19
23
  )
20
24
 
21
25
  __all__ = [
22
26
  # iam_actions
23
- "action_callback", "action_exchange",
24
- "action_login", "action_logout", "action_token",
27
+ "iam_callback", "iam_exchange",
28
+ "iam_login", "iam_logout", "iam_get_token", "iam_userinfo",
25
29
  # iam_commons
26
30
  "IamServer", "IamParam",
27
31
  # iam_pomes
28
- "iam_setup", "iam_get_env_parameters", "iam_get_token",
32
+ "iam_setup_server", "iam_setup_endpoints",
29
33
  # iam_services
30
- "jwt_required", "logger_register",
34
+ "jwt_required", "iam_setup_logger",
35
+ "service_setup_server", "service_login", "service_logout",
36
+ "service_get_token", "service_userinfo", "service_callback",
37
+ "service_exchange", "service_callback_exchange",
31
38
  # provider_pomes
32
- "provider_register", "provider_get_token",
39
+ "provider_setup_server", "provider_get_token",
40
+ "provider_setup_endpoint", "provider_setup_logger", "provider_setup_server",
33
41
  # token_pomes
34
- "token_validate"
42
+ "token_get_claims", "token_get_values", "token_validate"
35
43
  ]
36
44
 
37
45
  from importlib.metadata import version
@@ -13,24 +13,30 @@ from .iam_common import (
13
13
  _get_iam_users, _get_iam_registry, _get_public_key,
14
14
  _get_login_timeout, _get_user_data, _iam_server_from_issuer
15
15
  )
16
- from .token_pomes import token_get_claims, token_validate
16
+ from .token_pomes import token_get_values, token_validate
17
17
 
18
18
 
19
- def action_login(iam_server: IamServer,
20
- args: dict[str, Any],
21
- errors: list[str] = None,
22
- logger: Logger = None) -> str:
19
+ def iam_login(iam_server: IamServer,
20
+ args: dict[str, Any],
21
+ errors: list[str] = None,
22
+ logger: Logger = None) -> str:
23
23
  """
24
24
  Build the URL for redirecting the request to *iam_server*'s authentication page.
25
25
 
26
26
  These are the expected attributes in *args*:
27
27
  - user-id: optional, identifies the reference user (alias: 'login')
28
28
  - redirect-uri: a parameter to be added to the query part of the returned URL
29
+ -target-idp: optionally, identify a target identity provider for the login operation
29
30
 
30
31
  If provided, the user identification will be validated against the authorization data
31
32
  returned by *iam_server* upon login. On success, the appropriate URL for invoking
32
33
  the IAM server's authentication page is returned.
33
34
 
35
+ if 'target_idp' is provided as an attribute in *args*, the OAuth2 state variable included in the
36
+ returned URL will be postfixed with the string *#idp=<target-idp>*. At the callback endpoint,
37
+ this instructs *iam_server* to act as a broker, forwading the authentication process to the
38
+ *IAM* server *target-idp*.
39
+
34
40
  :param iam_server: the reference registered *IAM* server
35
41
  :param args: the arguments passed when requesting the service
36
42
  :param errors: incidental error messages
@@ -43,9 +49,14 @@ def action_login(iam_server: IamServer,
43
49
  # obtain the optional user's identification
44
50
  user_id: str = args.get("user-id") or args.get("login")
45
51
 
52
+ # obtain the optional target identity provider
53
+ target_idp: str = args.get("target-idp")
54
+
46
55
  # build the user data
47
56
  # ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
48
57
  oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
58
+ if target_idp:
59
+ oauth_state += f"#idp={target_idp}"
49
60
 
50
61
  with _iam_lock:
51
62
  # retrieve the user data from the IAM server's registry
@@ -75,19 +86,23 @@ def action_login(iam_server: IamServer,
75
86
  f"&client_id={registry[IamParam.CLIENT_ID]}"
76
87
  f"&redirect_uri={redirect_uri}"
77
88
  f"&state={oauth_state}")
89
+ if target_idp:
90
+ # HAZARD: the name 'kc_idp_hint' is Keycloak-specific
91
+ result += f"&kc_idp_hint={target_idp}"
92
+
78
93
  return result
79
94
 
80
95
 
81
- def action_logout(iam_server: IamServer,
82
- args: dict[str, Any],
83
- errors: list[str] = None,
84
- logger: Logger = None) -> None:
96
+ def iam_logout(iam_server: IamServer,
97
+ args: dict[str, Any],
98
+ errors: list[str] = None,
99
+ logger: Logger = None) -> None:
85
100
  """
86
101
  Logout the user, by removing all data associating it from *iam_server*'s registry.
87
102
 
88
- The user is identified by the attribute *user-id* or "login", provided in *args*.
89
- If successful, remove all data relating to the user from the *IAM* server's registry.
90
- Otherwise, this operation fails silently, unless an error has ocurred.
103
+ The user is identified by the attribute *user-id* or *login*, provided in *args*.
104
+ A logout request is sent to *iam_server* and, if successful, remove all data relating to the user
105
+ from the *IAM* server's registry.
91
106
 
92
107
  :param iam_server: the reference registered *IAM* server
93
108
  :param args: the arguments passed when requesting the service
@@ -99,33 +114,90 @@ def action_logout(iam_server: IamServer,
99
114
 
100
115
  if user_id:
101
116
  with _iam_lock:
102
- # retrieve the data for all users in the IAM server's registry
103
- users: dict[str, dict[str, Any]] = _get_iam_users(iam_server=iam_server,
104
- errors=errors,
105
- logger=logger) or {}
106
- if user_id in users:
107
- users.pop(user_id)
108
- if logger:
109
- logger.debug(msg=f"User '{user_id}' removed from {iam_server}'s registry")
117
+ # retrieve the IAM server's registry and the data for all users therein
118
+ registry: dict[str, Any] = _get_iam_registry(iam_server,
119
+ errors=errors,
120
+ logger=logger)
121
+ users: dict[str, dict[str, Any]] = registry[IamParam.USERS] if registry else {}
122
+ user_data: dict[str, Any] = users.get(user_id)
123
+ if user_data:
124
+ # request the IAM server to logout 'client_id'
125
+ client_secret: str = __get_client_secret(iam_server=iam_server,
126
+ errors=errors,
127
+ logger=logger)
128
+ if client_secret:
129
+ url: str = (f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
130
+ "/protocol/openid-connect/logout")
131
+ header_data: dict[str, str] = {
132
+ "Content-Type": "application/x-www-form-urlencoded"
133
+ }
134
+ body_data: dict[str, Any] = {
135
+ "client_id": registry[IamParam.CLIENT_ID],
136
+ "client_secret": client_secret,
137
+ "refresh_token": user_data[UserParam.REFRESH_TOKEN]
138
+ }
139
+ # log the POST
140
+ if logger:
141
+ logger.debug(msg=f"POST {url}")
142
+ try:
143
+ response: requests.Response = requests.post(url=url,
144
+ headers=header_data,
145
+ data=body_data)
146
+ if response.status_code in [200, 204]:
147
+ # request succeeded
148
+ if logger:
149
+ logger.debug(msg=f"POST success")
150
+ else:
151
+ # request failed, report the problem
152
+ msg: str = f"POST failure, status {response.status_code}, reason {response.reason}"
153
+ if logger:
154
+ logger.error(msg=msg)
155
+ if isinstance(errors, list):
156
+ errors.append(msg)
157
+ except Exception as e:
158
+ # the operation raised an exception
159
+ msg: str = exc_format(exc=e,
160
+ exc_info=sys.exc_info())
161
+ if logger:
162
+ logger.error(msg=msg)
163
+ if isinstance(errors, list):
164
+ errors.append(msg)
110
165
 
166
+ if not errors and user_id in users:
167
+ users.pop(user_id)
168
+ if logger:
169
+ logger.debug(msg=f"User '{user_id}' removed from {iam_server}'s registry")
170
+ else:
171
+ msg: str = "User identification not provided"
172
+ if logger:
173
+ logger.error(msg=msg)
174
+ if isinstance(errors, list):
175
+ errors.append(msg)
111
176
 
112
- def action_token(iam_server: IamServer,
113
- args: dict[str, Any],
114
- errors: list[str] = None,
115
- logger: Logger = None) -> str:
177
+
178
+ def iam_get_token(iam_server: IamServer,
179
+ args: dict[str, Any],
180
+ errors: list[str] = None,
181
+ logger: Logger = None) -> dict[str, str]:
116
182
  """
117
183
  Retrieve the authentication token for the user, from *iam_server*.
118
184
 
119
185
  The user is identified by the attribute *user-id* or *login*, provided in *args*.
120
186
 
187
+ On success, the returned *dict* will contain the following JSON:
188
+ {
189
+ "access-token": <token>,
190
+ "user-id": <user-identification
191
+ }
192
+
121
193
  :param iam_server: the reference registered *IAM* server
122
194
  :param args: the arguments passed when requesting the service
123
195
  :param errors: incidental error messages
124
196
  :param logger: optional logger
125
- :return: the token for user indicated, or *None* if error
197
+ :return: the user identification and token issued, or *None* if error
126
198
  """
127
199
  # initialize the return variable
128
- result: str | None = None
200
+ result: dict[str, str] | None = None
129
201
 
130
202
  # obtain the user's identification
131
203
  user_id: str = args.get("user-id") or args.get("login")
@@ -144,7 +216,10 @@ def action_token(iam_server: IamServer,
144
216
  access_expiration: int = user_data.get(UserParam.ACCESS_EXPIRATION)
145
217
  now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
146
218
  if now < access_expiration:
147
- result = token
219
+ result = {
220
+ "access-token": token,
221
+ "user-id": user_id
222
+ }
148
223
  else:
149
224
  # access token has expired
150
225
  refresh_token: str = user_data[UserParam.REFRESH_TOKEN]
@@ -152,7 +227,7 @@ def action_token(iam_server: IamServer,
152
227
  refresh_expiration: int = user_data[UserParam.REFRESH_EXPIRATION]
153
228
  if now < refresh_expiration:
154
229
  header_data: dict[str, str] = {
155
- "Content-Type": "application/json"
230
+ "Content-Type": "application/x-www-form-urlencoded"
156
231
  }
157
232
  body_data: dict[str, str] = {
158
233
  "grant_type": "refresh_token",
@@ -172,7 +247,10 @@ def action_token(iam_server: IamServer,
172
247
  now=now,
173
248
  errors=errors,
174
249
  logger=logger)
175
- result = token_info[1]
250
+ result = {
251
+ "access-token": token_info[1],
252
+ "user-id": user_id
253
+ }
176
254
  else:
177
255
  # refresh token is no longer valid
178
256
  user_data[UserParam.REFRESH_TOKEN] = None
@@ -200,10 +278,10 @@ def action_token(iam_server: IamServer,
200
278
  return result
201
279
 
202
280
 
203
- def action_callback(iam_server: IamServer,
204
- args: dict[str, Any],
205
- errors: list[str] = None,
206
- logger: Logger = None) -> tuple[str, str] | None:
281
+ def iam_callback(iam_server: IamServer,
282
+ args: dict[str, Any],
283
+ errors: list[str] = None,
284
+ logger: Logger = None) -> tuple[str, str] | None:
207
285
  """
208
286
  Entry point for the callback from *iam_server* via the front-end application, on authentication operations.
209
287
 
@@ -211,6 +289,10 @@ def action_callback(iam_server: IamServer,
211
289
  - *state*: used to enhance security during the authorization process, typically to provide *CSRF* protection
212
290
  - *code*: the temporary authorization code provided by *iam_server*, to be exchanged for the token
213
291
 
292
+ if *state* is postfixed with the string *#idp=<target-idp>*, this instructs *iam_server* to act as a broker,
293
+ forwarding the authentication process to the *IAM* server *target-idp*. This mechanism fully dispenses with
294
+ the flows 'callback-exchange', and 'callback' followed by 'exchange'.
295
+
214
296
  :param iam_server: the reference registered *IAM* server
215
297
  :param args: the arguments passed when requesting the service
216
298
  :param errors: incidental errors
@@ -240,6 +322,10 @@ def action_callback(iam_server: IamServer,
240
322
  if int(datetime.now(tz=TZ_LOCAL).timestamp()) > expiration:
241
323
  errors.append("Operation timeout")
242
324
  else:
325
+ pos: int = oauth_state.rfind("#idp=")
326
+ target_idp: str = oauth_state[pos+4:] if pos > 0 else None
327
+ target_iam = IamServer(target_idp) if target_idp in IamServer else None
328
+ target_data: dict[str, Any] = user_data.copy() if target_iam else None
243
329
  users.pop(oauth_state)
244
330
  code: str = args.get("code")
245
331
  header_data: dict[str, str] = {
@@ -264,6 +350,33 @@ def action_callback(iam_server: IamServer,
264
350
  now=now,
265
351
  errors=errors,
266
352
  logger=logger)
353
+ if target_iam:
354
+ if logger:
355
+ logger.debug(msg=f"Requesting to IAM server '{iam_server}' "
356
+ f"the token issued by '{target_iam}' ")
357
+ registry: dict[str, Any] = _get_iam_registry(iam_server,
358
+ errors=errors,
359
+ logger=logger)
360
+ url: str = (f"{registry[IamParam.URL_BASE]}/realms/"
361
+ f"{registry[IamParam.CLIENT_REALM]}/broker/{target_idp}/token")
362
+ header_data: dict[str, str] = {
363
+ "Authorization": f"Bearer {result[1]}",
364
+ "Content-Type": "application/json"
365
+ }
366
+ token_data = __get_for_data(url=url,
367
+ header_data=header_data,
368
+ params=None,
369
+ errors=errors,
370
+ logger=logger)
371
+ if not errors:
372
+ token_info: tuple[str, str] = __validate_and_store(iam_server=target_iam,
373
+ user_data=target_data,
374
+ token_data=token_data,
375
+ now=now,
376
+ errors=errors,
377
+ logger=logger)
378
+ if token_info and logger:
379
+ logger.debug(msg=f"Token obtained: {json.dumps(obj=token_info)}")
267
380
  else:
268
381
  msg: str = f"State '{oauth_state}' not found in {iam_server}'s registry"
269
382
  if logger:
@@ -274,10 +387,10 @@ def action_callback(iam_server: IamServer,
274
387
  return result
275
388
 
276
389
 
277
- def action_exchange(iam_server: IamServer,
278
- args: dict[str, Any],
279
- errors: list[str] = None,
280
- logger: Logger = None) -> tuple[str, str]:
390
+ def iam_exchange(iam_server: IamServer,
391
+ args: dict[str, Any],
392
+ errors: list[str] = None,
393
+ logger: Logger = None) -> tuple[str, str]:
281
394
  """
282
395
  Request *iam_server* to issue a token in exchange for the token obtained from another *IAM* server.
283
396
 
@@ -308,12 +421,10 @@ def action_exchange(iam_server: IamServer,
308
421
 
309
422
  # obtain the token to be exchanged
310
423
  token: str = args.get("access-token") if user_id else None
311
- token_claims: dict[str, dict[str, Any]] = token_get_claims(token=token,
312
- errors=errors,
313
- logger=logger) if token else None
314
- token_issuer: str = _iam_server_from_issuer(issuer=token_claims["payload"]["iss"],
424
+ token_issuer: tuple[str] = token_get_values(token=token,
425
+ keys=("iss",),
315
426
  errors=errors,
316
- logger=logger) if token_claims else None
427
+ logger=logger)
317
428
  if not errors:
318
429
  # HAZARD: only 'IAM_KEYCLOAK' is currently supported
319
430
  with _iam_lock:
@@ -326,6 +437,7 @@ def action_exchange(iam_server: IamServer,
326
437
  __assert_link(iam_server=iam_server,
327
438
  user_id=user_id,
328
439
  token=token,
440
+ token_issuer=token_issuer[0],
329
441
  errors=errors,
330
442
  logger=logger)
331
443
  if not errors:
@@ -368,9 +480,64 @@ def action_exchange(iam_server: IamServer,
368
480
  return result
369
481
 
370
482
 
483
+ def iam_userinfo(iam_server: IamServer,
484
+ args: dict[str, Any],
485
+ errors: list[str] = None,
486
+ logger: Logger = None) -> dict[str, Any] | None:
487
+ """
488
+ Obtain user data from *iam_server*.
489
+
490
+ The user is identified by the attribute *user-id* or *login*, provided in *args*.
491
+
492
+ :param iam_server: the reference registered *IAM* server
493
+ :param args: the arguments passed when requesting the service
494
+ :param errors: incidental error messages
495
+ :param logger: optional logger
496
+ :return: the user information requested, or *None* if error
497
+ """
498
+ # initialize the return variable
499
+ result: dict[str, Any] | None = None
500
+
501
+ # obtain the user's identification
502
+ user_id: str = args.get("user-id") or args.get("login")
503
+
504
+ err_msg: str | None = None
505
+ if user_id:
506
+ with _iam_lock:
507
+ # retrieve the IAM server's registry and the user data therein
508
+ registry: dict[str, Any] = _get_iam_registry(iam_server,
509
+ errors=errors,
510
+ logger=logger)
511
+ user_data: dict[str, Any] = registry[IamParam.USERS].get(user_id)
512
+ if user_data:
513
+ url: str = (f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
514
+ "/protocol/openid-connect/userinfo")
515
+ header_data: dict[str, str] = {
516
+ "Authorization": f"Bearer {args.get('access-token')}"
517
+ }
518
+ result = __get_for_data(url=url,
519
+ header_data=header_data,
520
+ params=None,
521
+ errors=errors,
522
+ logger=logger)
523
+ else:
524
+ err_msg = f"Unknown user '{user_id}'"
525
+ else:
526
+ err_msg: str = "User identification not provided"
527
+
528
+ if err_msg:
529
+ if logger:
530
+ logger.error(msg=err_msg)
531
+ if isinstance(errors, list):
532
+ errors.append(err_msg)
533
+
534
+ return result
535
+
536
+
371
537
  def __assert_link(iam_server: IamServer,
372
538
  user_id: str,
373
539
  token: str,
540
+ token_issuer: str,
374
541
  errors: list[str] | None,
375
542
  logger: Logger | None) -> None:
376
543
  """
@@ -398,7 +565,7 @@ def __assert_link(iam_server: IamServer,
398
565
  # obtain the internal user identification for 'user_id'
399
566
  if logger:
400
567
  logger.debug(msg="Obtaining internal identification "
401
- f"for user {user_id} in IAM server {iam_server}")
568
+ f"for user '{user_id}' in IAM server '{iam_server}'")
402
569
  url: str = f"{registry[IamParam.URL_BASE]}/admin/realms/{registry[IamParam.CLIENT_REALM]}/users"
403
570
  header_data: dict[str, str] = {
404
571
  "Authorization": f"Bearer {admin_token}",
@@ -414,12 +581,12 @@ def __assert_link(iam_server: IamServer,
414
581
  errors=errors,
415
582
  logger=logger)
416
583
  if users:
417
- # verify whether the 'oidc' protocol is referred to in an
418
- # association between 'user_id' and the internal user identification
584
+ # verify whether the IAM server that issued the token is a federated identity provider
585
+ # in the associations between 'user_id' and the internal user identification
419
586
  internal_id: str = users[0].get("id")
420
587
  if logger:
421
- logger.debug(msg="Obtaining the providers federated with "
422
- f"IAM server '{iam_server}' for internal identification '{internal_id}'")
588
+ logger.debug(msg="Obtaining the providers federated in IAM server "
589
+ f"'{iam_server}', for internal identification '{internal_id}'")
423
590
  url = (f"{registry[IamParam.URL_BASE]}/admin/realms/"
424
591
  f"{registry[IamParam.CLIENT_REALM]}/users/{internal_id}/federated-identity")
425
592
  providers: list[dict[str, Any]] = __get_for_data(url=url,
@@ -428,13 +595,9 @@ def __assert_link(iam_server: IamServer,
428
595
  errors=errors,
429
596
  logger=logger)
430
597
  no_link: bool = True
431
- claims: dict[str, dict[str, Any]] = token_get_claims(token=token,
432
- errors=errors,
433
- logger=logger)
434
- issuer: str = claims["payload"]["iss"] if claims else None
435
- provider_name: str = _iam_server_from_issuer(issuer=issuer,
598
+ provider_name: str = _iam_server_from_issuer(issuer=token_issuer,
436
599
  errors=errors,
437
- logger=logger) if issuer else None
600
+ logger=logger)
438
601
  if provider_name:
439
602
  for provider in providers:
440
603
  if provider.get("identityProvider") == provider_name:
@@ -442,17 +605,17 @@ def __assert_link(iam_server: IamServer,
442
605
  break
443
606
  if no_link:
444
607
  # link the identities
445
- claims: dict[str, dict[str, Any]] = token_get_claims(token=token,
446
- errors=errors,
447
- logger=logger)
448
- if claims:
449
- token_sub: str = claims["payload"]["sub"]
608
+ token_sub: tuple[str] = token_get_values(token=token,
609
+ keys=("sub",),
610
+ errors=errors,
611
+ logger=logger)
612
+ if token_sub:
450
613
  if logger:
451
614
  logger.debug(msg="Creating an association between identifications "
452
- f"'{user_id}' and '{token_sub}' in IAM server {iam_server}")
615
+ f"'{user_id}' and '{token_sub}' in IAM server '{iam_server}'")
453
616
  url += f"/{provider_name}"
454
617
  json_data: dict[str, Any] = {
455
- "userId": token_sub,
618
+ "userId": token_sub[0],
456
619
  "userName": user_id
457
620
  }
458
621
  __post_json(url=url,
@@ -756,8 +919,8 @@ def __post_for_token(iam_server: IamServer,
756
919
  body_data["client_id"] = registry[IamParam.CLIENT_ID]
757
920
 
758
921
  # build the URL
759
- base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
760
- url: str = f"{base_url}/protocol/openid-connect/token"
922
+ url: str = (f"{registry[IamParam.URL_BASE]}/realms/"
923
+ f"{registry[IamParam.CLIENT_REALM]}/protocol/openid-connect/token")
761
924
  # 'client_secret' data must not be shown in log
762
925
  msg: str = f"POST {url}, {json.dumps(obj=body_data,
763
926
  ensure_ascii=False)}"
@@ -839,6 +1002,8 @@ def __validate_and_store(iam_server: IamServer,
839
1002
  # initialize the return variable
840
1003
  result: tuple[str, str] | None = None
841
1004
 
1005
+ if logger:
1006
+ logger.debug(msg=f"Validating and storing the token")
842
1007
  with _iam_lock:
843
1008
  # retrieve the IAM server's registry
844
1009
  registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,