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.

@@ -0,0 +1,878 @@
1
+ import json
2
+ import requests
3
+ import secrets
4
+ import string
5
+ import sys
6
+ from datetime import datetime
7
+ from logging import Logger
8
+ from pypomes_core import TZ_LOCAL, exc_format
9
+ from typing import Any
10
+
11
+ from .iam_common import (
12
+ IamServer, IamParam, UserParam, _iam_lock,
13
+ _get_iam_users, _get_iam_registry, _get_public_key,
14
+ _get_login_timeout, _get_user_data, _iam_server_from_issuer
15
+ )
16
+ from .token_pomes import token_get_claims, token_validate
17
+
18
+
19
+ def action_login(iam_server: IamServer,
20
+ args: dict[str, Any],
21
+ errors: list[str] = None,
22
+ logger: Logger = None) -> str:
23
+ """
24
+ Build the URL for redirecting the request to *iam_server*'s authentication page.
25
+
26
+ These are the expected attributes in *args*:
27
+ - user-id: optional, identifies the reference user (alias: 'login')
28
+ - redirect-uri: a parameter to be added to the query part of the returned URL
29
+
30
+ If provided, the user identification will be validated against the authorization data
31
+ returned by *iam_server* upon login. On success, the appropriate URL for invoking
32
+ the IAM server's authentication page is returned.
33
+
34
+ :param iam_server: the reference registered *IAM* server
35
+ :param args: the arguments passed when requesting the service
36
+ :param errors: incidental error messages
37
+ :param logger: optional logger
38
+ :return: the callback URL, with the appropriate parameters, of *None* if error
39
+ """
40
+ # initialize the return variable
41
+ result: str | None = None
42
+
43
+ # obtain the optional user's identification
44
+ user_id: str = args.get("user-id") or args.get("login")
45
+
46
+ # build the user data
47
+ # ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
48
+ oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
49
+
50
+ with _iam_lock:
51
+ # retrieve the user data from the IAM server's registry
52
+ user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
53
+ user_id=oauth_state,
54
+ errors=errors,
55
+ logger=logger)
56
+ if user_data:
57
+ user_data[UserParam.LOGIN_ID] = user_id
58
+ timeout: int = _get_login_timeout(iam_server=iam_server,
59
+ errors=errors,
60
+ logger=logger)
61
+ if not errors:
62
+ user_data[UserParam.LOGIN_EXPIRATION] = (int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout) \
63
+ if timeout else None
64
+ redirect_uri: str = args.get(UserParam.REDIRECT_URI)
65
+ user_data[UserParam.REDIRECT_URI] = 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
+ base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
73
+ result = (f"{base_url}/protocol/openid-connect/auth"
74
+ f"?response_type=code&scope=openid"
75
+ f"&client_id={registry[IamParam.CLIENT_ID]}"
76
+ f"&redirect_uri={redirect_uri}"
77
+ f"&state={oauth_state}")
78
+ return result
79
+
80
+
81
+ def action_logout(iam_server: IamServer,
82
+ args: dict[str, Any],
83
+ errors: list[str] = None,
84
+ logger: Logger = None) -> None:
85
+ """
86
+ Logout the user, by removing all data associating it from *iam_server*'s registry.
87
+
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.
91
+
92
+ :param iam_server: the reference registered *IAM* server
93
+ :param args: the arguments passed when requesting the service
94
+ :param errors: incidental error messages
95
+ :param logger: optional logger
96
+ """
97
+ # obtain the user's identification
98
+ user_id: str = args.get("user-id") or args.get("login")
99
+
100
+ if user_id:
101
+ 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")
110
+
111
+
112
+ def action_token(iam_server: IamServer,
113
+ args: dict[str, Any],
114
+ errors: list[str] = None,
115
+ logger: Logger = None) -> str:
116
+ """
117
+ Retrieve the authentication token for the user, from *iam_server*.
118
+
119
+ The user is identified by the attribute *user-id* or *login*, provided in *args*.
120
+
121
+ :param iam_server: the reference registered *IAM* server
122
+ :param args: the arguments passed when requesting the service
123
+ :param errors: incidental error messages
124
+ :param logger: optional logger
125
+ :return: the token for user indicated, or *None* if error
126
+ """
127
+ # initialize the return variable
128
+ result: str | None = None
129
+
130
+ # obtain the user's identification
131
+ user_id: str = args.get("user-id") or args.get("login")
132
+
133
+ err_msg: str | None = None
134
+ if user_id:
135
+ with _iam_lock:
136
+ # retrieve the user data in the IAM server's registry
137
+ user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
138
+ user_id=user_id,
139
+ errors=errors,
140
+ logger=logger)
141
+ # retrieve the stored access token
142
+ token: str = user_data[UserParam.ACCESS_TOKEN] if user_data else None
143
+ if token:
144
+ access_expiration: int = user_data.get(UserParam.ACCESS_EXPIRATION)
145
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
146
+ if now < access_expiration:
147
+ result = token
148
+ else:
149
+ # access token has expired
150
+ refresh_token: str = user_data[UserParam.REFRESH_TOKEN]
151
+ if refresh_token:
152
+ refresh_expiration: int = user_data[UserParam.REFRESH_EXPIRATION]
153
+ if now < refresh_expiration:
154
+ header_data: dict[str, str] = {
155
+ "Content-Type": "application/json"
156
+ }
157
+ body_data: dict[str, str] = {
158
+ "grant_type": "refresh_token",
159
+ "refresh_token": refresh_token
160
+ }
161
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
162
+ token_data: dict[str, Any] = __post_for_token(iam_server=iam_server,
163
+ header_data=header_data,
164
+ body_data=body_data,
165
+ errors=errors,
166
+ logger=logger)
167
+ # validate and store the token data
168
+ if token_data:
169
+ token_info: tuple[str, str] = __validate_and_store(iam_server=iam_server,
170
+ user_data=user_data,
171
+ token_data=token_data,
172
+ now=now,
173
+ errors=errors,
174
+ logger=logger)
175
+ result = token_info[1]
176
+ else:
177
+ # refresh token is no longer valid
178
+ user_data[UserParam.REFRESH_TOKEN] = None
179
+ else:
180
+ # refresh token has expired
181
+ err_msg = "Access and refresh tokens expired"
182
+ if logger:
183
+ logger.error(msg=err_msg)
184
+ else:
185
+ err_msg = "Access token expired, no refresh token available"
186
+ if logger:
187
+ logger.error(msg=err_msg)
188
+ else:
189
+ err_msg = f"User '{user_id}' not authenticated"
190
+ if logger:
191
+ logger.error(msg=err_msg)
192
+ else:
193
+ err_msg = "User identification not provided"
194
+ if logger:
195
+ logger.error(msg=err_msg)
196
+
197
+ if err_msg and isinstance(errors, list):
198
+ errors.append(err_msg)
199
+
200
+ return result
201
+
202
+
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:
207
+ """
208
+ Entry point for the callback from *iam_server* via the front-end application, on authentication operations.
209
+
210
+ The relevant expected arguments in *args* are:
211
+ - *state*: used to enhance security during the authorization process, typically to provide *CSRF* protection
212
+ - *code*: the temporary authorization code provided by *iam_server*, to be exchanged for the token
213
+
214
+ :param iam_server: the reference registered *IAM* server
215
+ :param args: the arguments passed when requesting the service
216
+ :param errors: incidental errors
217
+ :param logger: optional logger
218
+ :return: a tuple containing the reference user identification and the token obtained, or *None* if error
219
+ """
220
+ # initialize the return variable
221
+ result: tuple[str, str] | None = None
222
+
223
+ with _iam_lock:
224
+ # retrieve the IAM server's data for all users
225
+ users: dict[str, dict[str, Any]] = _get_iam_users(iam_server=iam_server,
226
+ errors=errors,
227
+ logger=logger) or {}
228
+ # retrieve the OAuth2 state
229
+ oauth_state: str = args.get("state")
230
+ user_data: dict[str, Any] | None = None
231
+ if oauth_state:
232
+ for user, data in users.items():
233
+ if user == oauth_state:
234
+ user_data = data
235
+ break
236
+
237
+ # exchange 'code' received for the token
238
+ if user_data:
239
+ expiration: int = user_data["login-expiration"] or sys.maxsize
240
+ if int(datetime.now(tz=TZ_LOCAL).timestamp()) > expiration:
241
+ errors.append("Operation timeout")
242
+ else:
243
+ users.pop(oauth_state)
244
+ code: str = args.get("code")
245
+ header_data: dict[str, str] = {
246
+ "Content-Type": "application/x-www-form-urlencoded"
247
+ }
248
+ body_data: dict[str, Any] = {
249
+ "grant_type": "authorization_code",
250
+ "code": code,
251
+ "redirect_uri": user_data.pop("redirect-uri")
252
+ }
253
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
254
+ token_data: dict[str, Any] = __post_for_token(iam_server=iam_server,
255
+ header_data=header_data,
256
+ body_data=body_data,
257
+ errors=errors,
258
+ logger=logger)
259
+ # validate and store the token data
260
+ if token_data:
261
+ result = __validate_and_store(iam_server=iam_server,
262
+ user_data=user_data,
263
+ token_data=token_data,
264
+ now=now,
265
+ errors=errors,
266
+ logger=logger)
267
+ else:
268
+ msg: str = f"State '{oauth_state}' not found in {iam_server}'s registry"
269
+ if logger:
270
+ logger.error(msg=msg)
271
+ if isinstance(errors, list):
272
+ errors.append(msg)
273
+
274
+ return result
275
+
276
+
277
+ def action_exchange(iam_server: IamServer,
278
+ args: dict[str, Any],
279
+ errors: list[str] = None,
280
+ logger: Logger = None) -> dict[str, Any]:
281
+ """
282
+ Request *iam_server* to issue a token in exchange for the token obtained from another *IAM* server.
283
+
284
+ The expected parameters in *args* are:
285
+ - user-id: identification for the reference user (alias: 'login')
286
+ - token: the token to be exchanged
287
+
288
+ The typical data set returned contains the following attributes:
289
+ {
290
+ "token_type": "Bearer",
291
+ "access_token": <str>,
292
+ "expires_in": <number-of-seconds>,
293
+ "refresh_token": <str>,
294
+ "refesh_expires_in": <number-of-seconds>
295
+ }
296
+
297
+ :param iam_server: the reference registered *IAM* server
298
+ :param args: the arguments passed when requesting the service
299
+ :param errors: incidental errors
300
+ :param logger: optional logger
301
+ :return: the data for the new token, or *None* if error
302
+ """
303
+ # initialize the return variable
304
+ result: dict[str, Any] | None = None
305
+
306
+ # obtain the user's identification
307
+ user_id: str = args.get("user-id") or args.get("login")
308
+
309
+ # obtain the token to be exchanged
310
+ 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"],
315
+ errors=errors,
316
+ logger=logger) if token_claims else None
317
+ if not errors:
318
+ # HAZARD: only 'IAM_KEYCLOAK' is currently supported
319
+ with _iam_lock:
320
+ # retrieve the IAM server's registry
321
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
322
+ errors=errors,
323
+ logger=logger)
324
+ if registry:
325
+ # make sure 'client_id' is linked to the token's 'token_sub' at the IAM server
326
+ __assert_link(iam_server=iam_server,
327
+ user_id=user_id,
328
+ token=token,
329
+ errors=errors,
330
+ logger=logger)
331
+ if not errors:
332
+ # exchange the token
333
+ if logger:
334
+ logger.debug(msg=f"Requesting the token exchange to IAM server '{iam_server}'")
335
+ header_data: dict[str, Any] = {
336
+ "Content-Type": "application/x-www-form-urlencoded"
337
+ }
338
+ body_data: dict[str, str] = {
339
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
340
+ "subject_token": token,
341
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
342
+ "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
343
+ "audience": registry[IamParam.CLIENT_ID],
344
+ "subject_issuer": token_issuer
345
+ }
346
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
347
+ token_data: dict[str, Any] = __post_for_token(iam_server=iam_server,
348
+ header_data=header_data,
349
+ body_data=body_data,
350
+ errors=errors,
351
+ logger=logger)
352
+ # validate and store the token data
353
+ if token_data:
354
+ user_data: dict[str, Any] = {}
355
+ result = __validate_and_store(iam_server=iam_server,
356
+ user_data=user_data,
357
+ token_data=token_data,
358
+ now=now,
359
+ errors=errors,
360
+ logger=logger)
361
+ else:
362
+ msg: str = "User identification or token not provided"
363
+ if logger:
364
+ logger.error(msg=msg)
365
+ if isinstance(errors, list):
366
+ errors.append(msg)
367
+
368
+ return result
369
+
370
+
371
+ def __assert_link(iam_server: IamServer,
372
+ user_id: str,
373
+ token: str,
374
+ errors: list[str] | None,
375
+ logger: Logger | None) -> None:
376
+ """
377
+ Make sure *iam_server* has a link associating *user_id* to an internal user identification.
378
+ This is a requirement for exchanging a token issued by a federated *IAM* server for an equivalent
379
+ one from *iam_server*.
380
+
381
+ :param iam_server: the reference *IAM* server
382
+ :param user_id: the reference user identification
383
+ :param token: the reference token
384
+ :param errors: incidental errors
385
+ :param logger: optional logger
386
+ """
387
+ if logger:
388
+ logger.debug(msg="Verifying associations for user "
389
+ f"'{user_id}' in IAM server '{iam_server}'")
390
+ # obtain a token with administrative rights
391
+ admin_token: str = __get_administrative_token(iam_server=iam_server,
392
+ errors=errors,
393
+ logger=logger)
394
+ if admin_token:
395
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
396
+ errors=errors,
397
+ logger=logger)
398
+ # obtain the internal user identification for 'user_id'
399
+ if logger:
400
+ logger.debug(msg="Obtaining internal identification "
401
+ f"for user {user_id} in IAM server {iam_server}")
402
+ url: str = f"{registry[IamParam.URL_BASE]}/admin/realms/{registry[IamParam.CLIENT_REALM]}/users"
403
+ header_data: dict[str, str] = {
404
+ "Authorization": f"Bearer {admin_token}",
405
+ "Content-Type": "application/json"
406
+ }
407
+ params: dict[str, str] = {
408
+ "username": user_id,
409
+ "exact": "true"
410
+ }
411
+ users: list[dict[str, Any]] = __get_for_data(url=url,
412
+ header_data=header_data,
413
+ params=params,
414
+ errors=errors,
415
+ logger=logger)
416
+ if users:
417
+ # verify whether the 'oidc' protocol is referred to in an
418
+ # association between 'user_id' and the internal user identification
419
+ internal_id: str = users[0].get("id")
420
+ if logger:
421
+ logger.debug(msg="Obtaining the providers federated with "
422
+ f"IAM server '{iam_server}' for internal identification '{internal_id}'")
423
+ url = (f"{registry[IamParam.URL_BASE]}/admin/realms/"
424
+ f"{registry[IamParam.CLIENT_REALM]}/users/{internal_id}/federated-identity")
425
+ providers: list[dict[str, Any]] = __get_for_data(url=url,
426
+ header_data=header_data,
427
+ params=None,
428
+ errors=errors,
429
+ logger=logger)
430
+ 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,
436
+ errors=errors,
437
+ logger=logger) if issuer else None
438
+ if provider_name:
439
+ for provider in providers:
440
+ if provider.get("identityProvider") == provider_name:
441
+ no_link = False
442
+ break
443
+ if no_link:
444
+ # 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"]
450
+ if logger:
451
+ logger.debug(msg="Creating an association between identifications "
452
+ f"'{user_id}' and '{token_sub}' in IAM server {iam_server}")
453
+ url += f"/{provider_name}"
454
+ body_data: dict[str, Any] = {
455
+ "userId": token_sub,
456
+ "userName": user_id
457
+ }
458
+ __post_data(url=url,
459
+ header_data=header_data,
460
+ body_data=body_data,
461
+ errors=errors,
462
+ logger=logger)
463
+
464
+
465
+ def __get_administrative_token(iam_server: IamServer,
466
+ errors: list[str] | None,
467
+ logger: Logger | None) -> str:
468
+ """
469
+ Obtain a token with administrative rights from *iam_server*'s reference realm.
470
+
471
+ The reference realm is the realm specified at *iam_server*'s setup time. This operation requires
472
+ the realm administrator's identification and secret password to have also been provided.
473
+
474
+ :param iam_server: the reference *IAM* server
475
+ :param errors: incidental errors
476
+ :param logger: optional logger
477
+ :return: a token with administrative rights for the reference realm
478
+ """
479
+ # initialize the return variable
480
+ result: str | None = None
481
+
482
+ if logger:
483
+ logger.debug(msg="Requesting a token with "
484
+ f"administrative rights to IAM Server '{iam_server}'")
485
+
486
+ # obtain the IAM server's registry
487
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
488
+ errors=errors,
489
+ logger=logger)
490
+ if registry:
491
+ if registry[IamParam.ADMIN_ID] and registry[IamParam.ADMIN_SECRET]:
492
+ header_data: dict[str, str] = {
493
+ "Content-Type": "application/x-www-form-urlencoded"
494
+ }
495
+ body_data: dict[str, str] = {
496
+ "grant_type": "password",
497
+ "username": registry[IamParam.ADMIN_ID],
498
+ "password": registry[IamParam.ADMIN_SECRET],
499
+ "client_id": "admin-cli"
500
+ }
501
+ token_data: dict[str, Any] = __post_for_token(iam_server=iam_server,
502
+ header_data=header_data,
503
+ body_data=body_data,
504
+ errors=errors,
505
+ logger=logger)
506
+ if token_data:
507
+ # obtain the token
508
+ result = token_data["access_token"]
509
+ if logger:
510
+ logger.debug(msg="Administrative token obtained")
511
+
512
+ elif logger or isinstance(errors, list):
513
+ msg: str = ("Credentials for administrator of realm "
514
+ f"'{registry[IamParam.CLIENT_REALM]}' "
515
+ f"at IAM server '{iam_server}' not provided")
516
+ if logger:
517
+ logger.error(msg=msg)
518
+ if isinstance(errors, list):
519
+ errors.append(msg)
520
+
521
+ elif logger or isinstance(errors, list):
522
+ msg: str = f"Unknown IAM server {iam_server}"
523
+ if logger:
524
+ logger.error(msg=msg)
525
+ if isinstance(errors, list):
526
+ errors.append(msg)
527
+
528
+ return result
529
+
530
+
531
+ def __get_client_secret(iam_server: IamServer,
532
+ errors: list[str] | None,
533
+ logger: Logger | None) -> str:
534
+ """
535
+ Retrieve the client's secret password.
536
+
537
+ If it has not been provided at *iam_server*'s setup time, an attempt is made to obtain it
538
+ from the *IAM* server itself. This would require the realm administrator's identification and
539
+ secret password to have been provided, instead.
540
+
541
+ :param iam_server: the reference *IAM* server
542
+ :param errors: incidental errors
543
+ :param logger: optional logger
544
+ :return: the client's secret password, or *None* if error
545
+ """
546
+ # retrieve client's secret password stored in the IAM server's registry
547
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
548
+ errors=errors,
549
+ logger=logger)
550
+ result: str = registry[IamParam.CLIENT_SECRET] if registry else None
551
+
552
+ if not result and not errors:
553
+ # obtain a token with administrative rights
554
+ token: str = __get_administrative_token(iam_server=iam_server,
555
+ errors=errors,
556
+ logger=logger)
557
+ if token:
558
+ realm: str = registry[IamParam.CLIENT_REALM]
559
+ client_id: str = registry[IamParam.CLIENT_ID]
560
+ if logger:
561
+ logger.debug(msg=f"Obtaining the UUID for client '{client_id}', "
562
+ f"in realm '{realm}' at IAM server '{iam_server}'")
563
+ # obtain the client UUID
564
+ url: str = f"{registry[IamParam.URL_BASE]}/realms/{realm}/clients"
565
+ header_data: dict[str, str] = {
566
+ "Authorization": f"Bearer {token}",
567
+ "Content-Type": "application/json"
568
+ }
569
+ params: dict[str, str] = {
570
+ "clientId": client_id
571
+ }
572
+ clients: list[dict[str, Any]] = __get_for_data(url=url,
573
+ header_data=header_data,
574
+ params=params,
575
+ errors=errors,
576
+ logger=logger)
577
+ if clients:
578
+ # obtain the client's secret password
579
+ client_uuid: str = clients[0]["id"]
580
+ if logger:
581
+ logger.debug(msg=f"Obtaining the secret for client UUID '{client_uuid}', "
582
+ f"in realm '{realm}' at IAM server '{iam_server}'")
583
+ url += f"/{client_uuid}/client-secret"
584
+ reply: dict[str, Any] = __get_for_data(url=url,
585
+ header_data=header_data,
586
+ params=None,
587
+ errors=errors,
588
+ logger=logger)
589
+ if reply:
590
+ # store the client's secret password and return it
591
+ result = reply["value"]
592
+ registry[IamParam.CLIENT_ID] = result
593
+ return result
594
+
595
+
596
+ def __get_for_data(url: str,
597
+ header_data: dict[str, str],
598
+ params: dict[str, Any] | None,
599
+ errors: list[str] | None,
600
+ logger: Logger | None) -> Any:
601
+ """
602
+ Send a *GET* request to *url* and return the data obtained.
603
+
604
+ :param url: the target URL
605
+ :param header_data: the data to send in the header of the request
606
+ :param params: the query parameters to send in the request
607
+ :param errors: incidental errors
608
+ :param logger: optional logger
609
+ :return: the data requested, or *None* if error
610
+ """
611
+ # initialize the return variable
612
+ result: Any = None
613
+
614
+ # log the GET
615
+ if logger:
616
+ logger.debug(msg=f"GET {url}, {json.dumps(obj=params,
617
+ ensure_ascii=False)}")
618
+ try:
619
+ response: requests.Response = requests.get(url=url,
620
+ headers=header_data,
621
+ params=params)
622
+ if response.status_code == 200:
623
+ # request succeeded
624
+ result = response.json() or {}
625
+ if logger:
626
+ logger.debug(msg=f"GET success, {json.dumps(obj=result,
627
+ ensure_ascii=False)}")
628
+ else:
629
+ # request failed, report the problem
630
+ msg: str = f"GET failure, status {response.status_code}, reason {response.reason}"
631
+ if hasattr(response, "content") and response.content:
632
+ msg += f", content '{response.content}'"
633
+ if logger:
634
+ logger.error(msg=msg)
635
+ if isinstance(errors, list):
636
+ errors.append(msg)
637
+ except Exception as e:
638
+ # the operation raised an exception
639
+ msg: str = exc_format(exc=e,
640
+ exc_info=sys.exc_info())
641
+ if logger:
642
+ logger.error(msg=msg)
643
+ if isinstance(errors, list):
644
+ errors.append(msg)
645
+
646
+ return result
647
+
648
+
649
+ def __post_data(url: str,
650
+ header_data: dict[str, str],
651
+ body_data: dict[str, Any],
652
+ errors: list[str] | None,
653
+ logger: Logger | None) -> None:
654
+ """
655
+ Submit a *POST* request to *url*.
656
+
657
+ :param header_data: the data to send in the header of the request
658
+ :param body_data: the data to send in the body of the request
659
+ :param errors: incidental errors
660
+ :param logger: optional logger
661
+ """
662
+ # log the POST
663
+ if logger:
664
+ logger.debug(msg=f"POST {url}, {json.dumps(obj=body_data,
665
+ ensure_ascii=False)}")
666
+ try:
667
+ response: requests.Response = requests.get(url=url,
668
+ headers=header_data,
669
+ data=body_data)
670
+ if response.status_code >= 400:
671
+ # request failed, report the problem
672
+ msg = f"POST failure, status {response.status_code}, reason {response.reason}"
673
+ if hasattr(response, "content") and response.content:
674
+ msg += f", content '{response.content}'"
675
+ if logger:
676
+ logger.error(msg=msg)
677
+ if isinstance(errors, list):
678
+ errors.append(msg)
679
+ elif logger:
680
+ logger.debug(msg=f"POST success")
681
+ except Exception as e:
682
+ # the operation raised an exception
683
+ msg = exc_format(exc=e,
684
+ exc_info=sys.exc_info())
685
+ if logger:
686
+ logger.error(msg=msg)
687
+ if isinstance(errors, list):
688
+ errors.append(msg)
689
+
690
+
691
+ def __post_for_token(iam_server: IamServer,
692
+ header_data: dict[str, str],
693
+ body_data: dict[str, Any],
694
+ errors: list[str] | None,
695
+ logger: Logger | None) -> dict[str, Any] | None:
696
+ """
697
+ Send a *POST* request to *iam_server* and return the authentication token data obtained.
698
+
699
+ For token acquisition, *body_data* will have the attributes:
700
+ - "grant_type": "authorization_code"
701
+ - "code": <16-character-random-code>
702
+ - "redirect_uri": <redirect-uri>
703
+
704
+ For token refresh, *body_data* will have the attributes:
705
+ - "grant_type": "refresh_token"
706
+ - "refresh_token": <current-refresh-token>
707
+
708
+ For token exchange, *body_data* will have the attributes:
709
+ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
710
+ - "subject_token": <token-to-be-exchanged>,
711
+ - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
712
+ - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
713
+ - "audience": <client-id>,
714
+ - "subject_issuer": "oidc"
715
+
716
+ For administrative token acquisition, *body_data* will have the attributes:
717
+ - "grant_type": "password"
718
+ - "username": <realm-administrator-identification>
719
+ - "password": <realm-administrator-secret>
720
+
721
+ These attributes are then added to *body_data*, except for acquiring administrative tokens:
722
+ - "client_id": <client-id>
723
+ - "client_secret": <client-secret>
724
+
725
+ If the operation is successful, the token data is stored in the *IAM* server's registry, and returned.
726
+ Otherwise, *errors* will contain the appropriate error message.
727
+
728
+ The typical data set returned contains the following attributes:
729
+ {
730
+ "token_type": "Bearer",
731
+ "access_token": <str>,
732
+ "expires_in": <number-of-seconds>,
733
+ "refresh_token": <str>,
734
+ "refesh_expires_in": <number-of-seconds>
735
+ }
736
+
737
+ :param iam_server: the reference registered *IAM* server
738
+ :param header_data: the data to send in the header of the request
739
+ :param body_data: the data to send in the body of the request
740
+ :param errors: incidental errors
741
+ :param logger: optional logger
742
+ :return: the token data, or *None* if error
743
+ """
744
+ # initialize the return variable
745
+ result: dict[str, Any] | None = None
746
+
747
+ err_msg: str | None = None
748
+ with _iam_lock:
749
+ # retrieve the IAM server's registry
750
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
751
+ errors=errors,
752
+ logger=logger)
753
+ if registry:
754
+ # complete the data to send in body of request
755
+ if body_data["grant_type"] != "password":
756
+ body_data["client_id"] = registry[IamParam.CLIENT_ID]
757
+
758
+ # 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"
761
+ # 'client_secret' data must not be shown in log
762
+ msg: str = f"POST {url}, {json.dumps(obj=body_data,
763
+ ensure_ascii=False)}"
764
+ if body_data["grant_type"] != "password":
765
+ # 'client_secret' not required for requesting tokens from staging environments
766
+ client_secret: str = __get_client_secret(iam_server=iam_server,
767
+ errors=None,
768
+ logger=logger)
769
+ if client_secret:
770
+ body_data["client_secret"] = client_secret
771
+ # log the POST
772
+ if logger:
773
+ logger.debug(msg=msg)
774
+
775
+ # obtain the token
776
+ try:
777
+ # typical return on a token request:
778
+ # {
779
+ # "token_type": "Bearer",
780
+ # "access_token": <str>,
781
+ # "expires_in": <number-of-seconds>,
782
+ # "refresh_token": <str>,
783
+ # "refesh_expires_in": <number-of-seconds>
784
+ # }
785
+ response: requests.Response = requests.post(url=url,
786
+ headers=header_data,
787
+ data=body_data)
788
+ if response.status_code == 200:
789
+ # request succeeded
790
+ result = response.json()
791
+ if logger:
792
+ logger.debug(msg=f"POST success, {json.dumps(obj=result,
793
+ ensure_ascii=False)}")
794
+ else:
795
+ # request failed, report the problem
796
+ err_msg = f"POST failure, status {response.status_code}, reason {response.reason}"
797
+ if hasattr(response, "content") and response.content:
798
+ err_msg += f", content '{response.content}'"
799
+ if logger:
800
+ logger.error(msg=err_msg)
801
+ except Exception as e:
802
+ # the operation raised an exception
803
+ err_msg = exc_format(exc=e,
804
+ exc_info=sys.exc_info())
805
+ if logger:
806
+ logger.error(msg=err_msg)
807
+
808
+ if err_msg and isinstance(errors, list):
809
+ errors.append(err_msg)
810
+
811
+ return result
812
+
813
+
814
+ def __validate_and_store(iam_server: IamServer,
815
+ user_data: dict[str, Any],
816
+ token_data: dict[str, Any],
817
+ now: int,
818
+ errors: list[str] | None,
819
+ logger: Logger) -> tuple[str, str] | None:
820
+ """
821
+ Validate and store the token data.
822
+
823
+ The typical *token_data* contains the following attributes:
824
+ {
825
+ "token_type": "Bearer",
826
+ "access_token": <str>,
827
+ "expires_in": <number-of-seconds>,
828
+ "refresh_token": <str>,
829
+ "refesh_expires_in": <number-of-seconds>
830
+ }
831
+
832
+ :param iam_server: the reference registered *IAM* server
833
+ :param user_data: the aurthentication data kepth in *iam_server*'s registry
834
+ :param token_data: the token data
835
+ :param errors: incidental errors
836
+ :param logger: optional logger
837
+ :return: tuple containing the user identification and the validated and stored token, or *None* if error
838
+ """
839
+ # initialize the return variable
840
+ result: tuple[str, str] | None = None
841
+
842
+ with _iam_lock:
843
+ # retrieve the IAM server's registry
844
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
845
+ errors=errors,
846
+ logger=logger)
847
+ if registry:
848
+ token: str = token_data.get("access_token")
849
+ user_data["access-token"] = token
850
+ # keep current refresh token if a new one is not provided
851
+ if token_data.get("refresh_token"):
852
+ user_data["refresh-token"] = token_data.get("refresh_token")
853
+ user_data["access-expiration"] = now + token_data.get("expires_in")
854
+ refresh_exp: int = user_data.get("refresh_expires_in")
855
+ user_data["refresh-expiration"] = (now + refresh_exp) if refresh_exp else sys.maxsize
856
+ public_key: str = _get_public_key(iam_server=iam_server,
857
+ errors=errors,
858
+ logger=logger)
859
+ recipient_attr = registry[IamParam.RECIPIENT_ATTR]
860
+ login_id = user_data.pop("login-id", None)
861
+ base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
862
+ claims: dict[str, dict[str, Any]] = token_validate(token=token,
863
+ issuer=base_url,
864
+ recipient_id=login_id,
865
+ recipient_attr=recipient_attr,
866
+ public_key=public_key,
867
+ errors=errors,
868
+ logger=logger)
869
+ if claims:
870
+ users: dict[str, dict[str, Any]] = _get_iam_users(iam_server=iam_server,
871
+ errors=errors,
872
+ logger=logger)
873
+ # must test with 'not errors'
874
+ if not errors:
875
+ user_id: str = login_id if login_id else claims["payload"][recipient_attr]
876
+ users[user_id] = user_data
877
+ result = (user_id, token)
878
+ return result