pypomes-iam 0.5.1__py3-none-any.whl → 0.6.9__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.
@@ -0,0 +1,870 @@
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
+ if token:
312
+ # HAZARD: only 'IAM_KEYCLOAK' is currently supported
313
+ with _iam_lock:
314
+ # retrieve the IAM server's registry
315
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
316
+ errors=errors,
317
+ logger=logger)
318
+ if registry:
319
+ # make sure 'client_id' is linked to the token's 'token_sub' at the IAM server
320
+ __assert_link(iam_server=iam_server,
321
+ user_id=user_id,
322
+ token=token,
323
+ errors=errors,
324
+ logger=logger)
325
+ if not errors:
326
+ # exchange the token
327
+ header_data: dict[str, Any] = {
328
+ "Content-Type": "application/x-www-form-urlencoded"
329
+ }
330
+ body_data: dict[str, str] = {
331
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
332
+ "subject_token": token,
333
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
334
+ "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
335
+ "audience": registry[IamParam.CLIENT_ID],
336
+ "subject_issuer": "oidc"
337
+ }
338
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
339
+ token_data: dict[str, Any] = __post_for_token(iam_server=iam_server,
340
+ header_data=header_data,
341
+ body_data=body_data,
342
+ errors=errors,
343
+ logger=logger)
344
+ # validate and store the token data
345
+ if token_data:
346
+ user_data: dict[str, Any] = {}
347
+ result = __validate_and_store(iam_server=iam_server,
348
+ user_data=user_data,
349
+ token_data=token_data,
350
+ now=now,
351
+ errors=errors,
352
+ logger=logger)
353
+ else:
354
+ msg: str = "User identification or token not provided"
355
+ if logger:
356
+ logger.error(msg=msg)
357
+ if isinstance(errors, list):
358
+ errors.append(msg)
359
+
360
+ return result
361
+
362
+
363
+ def __assert_link(iam_server: IamServer,
364
+ user_id: str,
365
+ token: str,
366
+ errors: list[str] | None,
367
+ logger: Logger | None) -> None:
368
+ """
369
+ Make sure *iam_server* has a link associating *user_id* to an internal user identification.
370
+ This is a requirement for exchanging a token issued by a federated *IAM* server for an equivalent
371
+ one from *iam_server*.
372
+
373
+ :param iam_server: the reference *IAM* server
374
+ :param user_id: the reference user identification
375
+ :param token: the reference token
376
+ :param errors: incidental errors
377
+ :param logger: optional logger
378
+ """
379
+ if logger:
380
+ logger.debug(msg="Verifying associations for user "
381
+ f"'{user_id}' in IAM server '{iam_server}'")
382
+ # obtain a token with administrative rights
383
+ admin_token: str = __get_administrative_token(iam_server=iam_server,
384
+ errors=errors,
385
+ logger=logger)
386
+ if admin_token:
387
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
388
+ errors=errors,
389
+ logger=logger)
390
+ # obtain the internal user identification for 'user_id'
391
+ if logger:
392
+ logger.debug(msg="Obtaining internal identification "
393
+ f"for user {user_id} in IAM server {iam_server}")
394
+ url: str = f"{registry[IamParam.URL_BASE]}/admin/realms/{registry[IamParam.CLIENT_REALM]}/users"
395
+ header_data: dict[str, str] = {
396
+ "Authorization": f"Bearer {admin_token}",
397
+ "Content-Type": "application/json"
398
+ }
399
+ params: dict[str, str] = {
400
+ "username": user_id,
401
+ "exact": "true"
402
+ }
403
+ users: list[dict[str, Any]] = __get_for_data(url=url,
404
+ header_data=header_data,
405
+ params=params,
406
+ errors=errors,
407
+ logger=logger)
408
+ if users:
409
+ # verify whether the 'oidc' protocol is referred to in an
410
+ # association between 'user_id' and the internal user identification
411
+ internal_id: str = users[0].get("id")
412
+ if logger:
413
+ logger.debug(msg="Obtaining the providers federated with "
414
+ f"IAM server '{iam_server}' for internal identification '{internal_id}'")
415
+ url = (f"{registry[IamParam.URL_BASE]}/admin/realms/"
416
+ f"{registry[IamParam.CLIENT_REALM]}/users/{internal_id}/federated-identity")
417
+ providers: list[dict[str, Any]] = __get_for_data(url=url,
418
+ header_data=header_data,
419
+ params=None,
420
+ errors=errors,
421
+ logger=logger)
422
+ no_link: bool = True
423
+ claims: dict[str, dict[str, Any]] = token_get_claims(token=token,
424
+ errors=errors,
425
+ logger=logger)
426
+ issuer: str = claims["payload"]["iss"] if claims else None
427
+ provider_name: str = _iam_server_from_issuer(issuer=issuer,
428
+ errors=errors,
429
+ logger=logger) if issuer else None
430
+ if provider_name:
431
+ for provider in providers:
432
+ if provider.get("identityProvider") == provider_name:
433
+ no_link = False
434
+ break
435
+ if no_link:
436
+ # link the identities
437
+ claims: dict[str, dict[str, Any]] = token_get_claims(token=token,
438
+ errors=errors,
439
+ logger=logger)
440
+ if claims:
441
+ token_sub: str = claims["payload"]["sub"]
442
+ if logger:
443
+ logger.debug(msg="Creating an association between identifications "
444
+ f"'{user_id}' and '{token_sub}' in IAM server {iam_server}")
445
+ url += f"/{provider_name}"
446
+ body_data: dict[str, Any] = {
447
+ "userId": token_sub,
448
+ "userName": user_id
449
+ }
450
+ __post_data(url=url,
451
+ header_data=header_data,
452
+ body_data=body_data,
453
+ errors=errors,
454
+ logger=logger)
455
+
456
+
457
+ def __get_administrative_token(iam_server: IamServer,
458
+ errors: list[str] | None,
459
+ logger: Logger | None) -> str:
460
+ """
461
+ Obtain a token with administrative rights from *iam_server*'s reference realm.
462
+
463
+ The reference realm is the realm specified at *iam_server*'s setup time. This operation requires
464
+ the realm administrator's identification and secret password to have also been provided.
465
+
466
+ :param iam_server: the reference *IAM* server
467
+ :param errors: incidental errors
468
+ :param logger: optional logger
469
+ :return: a token with administrative rights for the reference realm
470
+ """
471
+ # initialize the return variable
472
+ result: str | None = None
473
+
474
+ if logger:
475
+ logger.debug(msg="Requesting a token with "
476
+ f"administrative rights to IAM Server '{iam_server}'")
477
+
478
+ # obtain the IAM server's registry
479
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
480
+ errors=errors,
481
+ logger=logger)
482
+ if registry:
483
+ if registry[IamParam.ADMIN_ID] and registry[IamParam.ADMIN_SECRET]:
484
+ header_data: dict[str, str] = {
485
+ "Content-Type": "application/x-www-form-urlencoded"
486
+ }
487
+ body_data: dict[str, str] = {
488
+ "grant_type": "password",
489
+ "username": registry[IamParam.ADMIN_ID],
490
+ "password": registry[IamParam.ADMIN_SECRET],
491
+ "client_id": "admin-cli"
492
+ }
493
+ token_data: dict[str, Any] = __post_for_token(iam_server=iam_server,
494
+ header_data=header_data,
495
+ body_data=body_data,
496
+ errors=errors,
497
+ logger=logger)
498
+ if token_data:
499
+ # obtain the token
500
+ result = token_data["access_token"]
501
+ if logger:
502
+ logger.debug(msg="Administrative token obtained")
503
+
504
+ elif logger or isinstance(errors, list):
505
+ msg: str = ("Credentials for administrator of realm "
506
+ f"'{registry[IamParam.CLIENT_REALM]}' "
507
+ f"at IAM server '{iam_server}' not provided")
508
+ if logger:
509
+ logger.error(msg=msg)
510
+ if isinstance(errors, list):
511
+ errors.append(msg)
512
+
513
+ elif logger or isinstance(errors, list):
514
+ msg: str = f"Unknown IAM server {iam_server}"
515
+ if logger:
516
+ logger.error(msg=msg)
517
+ if isinstance(errors, list):
518
+ errors.append(msg)
519
+
520
+ return result
521
+
522
+
523
+ def __get_client_secret(iam_server: IamServer,
524
+ errors: list[str] | None,
525
+ logger: Logger | None) -> str:
526
+ """
527
+ Retrieve the client's secret password.
528
+
529
+ If it has not been provided at *iam_server*'s setup time, an attempt is made to obtain it
530
+ from the *IAM* server itself. This would require the realm administrator's identification and
531
+ secret password to have been provided, instead.
532
+
533
+ :param iam_server: the reference *IAM* server
534
+ :param errors: incidental errors
535
+ :param logger: optional logger
536
+ :return: the client's secret password, or *None* if error
537
+ """
538
+ # retrieve client's secret password stored in the IAM server's registry
539
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
540
+ errors=errors,
541
+ logger=logger)
542
+ result: str = registry[IamParam.CLIENT_SECRET] if registry else None
543
+
544
+ if not result and not errors:
545
+ # obtain a token with administrative rights
546
+ token: str = __get_administrative_token(iam_server=iam_server,
547
+ errors=errors,
548
+ logger=logger)
549
+ if token:
550
+ realm: str = registry[IamParam.CLIENT_REALM]
551
+ client_id: str = registry[IamParam.CLIENT_ID]
552
+ if logger:
553
+ logger.debug(msg=f"Obtaining the UUID for client '{client_id}', "
554
+ f"in realm '{realm}' at IAM server '{iam_server}'")
555
+ # obtain the client UUID
556
+ url: str = f"{registry[IamParam.URL_BASE]}/realms/{realm}/clients"
557
+ header_data: dict[str, str] = {
558
+ "Authorization": f"Bearer {token}",
559
+ "Content-Type": "application/json"
560
+ }
561
+ params: dict[str, str] = {
562
+ "clientId": client_id
563
+ }
564
+ clients: list[dict[str, Any]] = __get_for_data(url=url,
565
+ header_data=header_data,
566
+ params=params,
567
+ errors=errors,
568
+ logger=logger)
569
+ if clients:
570
+ # obtain the client's secret password
571
+ client_uuid: str = clients[0]["id"]
572
+ if logger:
573
+ logger.debug(msg=f"Obtaining the secret for client UUID '{client_uuid}', "
574
+ f"in realm '{realm}' at IAM server '{iam_server}'")
575
+ url += f"/{client_uuid}/client-secret"
576
+ reply: dict[str, Any] = __get_for_data(url=url,
577
+ header_data=header_data,
578
+ params=None,
579
+ errors=errors,
580
+ logger=logger)
581
+ if reply:
582
+ # store the client's secret password and return it
583
+ result = reply["value"]
584
+ registry[IamParam.CLIENT_ID] = result
585
+ return result
586
+
587
+
588
+ def __get_for_data(url: str,
589
+ header_data: dict[str, str],
590
+ params: dict[str, Any] | None,
591
+ errors: list[str] | None,
592
+ logger: Logger | None) -> Any:
593
+ """
594
+ Send a *GET* request to *url* and return the data obtained.
595
+
596
+ :param url: the target URL
597
+ :param header_data: the data to send in the header of the request
598
+ :param params: the query parameters to send in the request
599
+ :param errors: incidental errors
600
+ :param logger: optional logger
601
+ :return: the data requested, or *None* if error
602
+ """
603
+ # initialize the return variable
604
+ result: Any = None
605
+
606
+ # log the GET
607
+ if logger:
608
+ logger.debug(msg=f"GET {url}, {json.dumps(obj=params,
609
+ ensure_ascii=False)}")
610
+ try:
611
+ response: requests.Response = requests.get(url=url,
612
+ headers=header_data,
613
+ params=params)
614
+ if response.status_code == 200:
615
+ # request succeeded
616
+ result = response.json() or {}
617
+ if logger:
618
+ logger.debug(msg=f"GET success, {json.dumps(obj=result,
619
+ ensure_ascii=False)}")
620
+ else:
621
+ # request failed, report the problem
622
+ msg: str = f"GET failure, status {response.status_code}, reason {response.reason}"
623
+ if hasattr(response, "content") and response.content:
624
+ msg += f", content '{response.content}'"
625
+ if logger:
626
+ logger.error(msg=msg)
627
+ if isinstance(errors, list):
628
+ errors.append(msg)
629
+ except Exception as e:
630
+ # the operation raised an exception
631
+ msg: str = exc_format(exc=e,
632
+ exc_info=sys.exc_info())
633
+ if logger:
634
+ logger.error(msg=msg)
635
+ if isinstance(errors, list):
636
+ errors.append(msg)
637
+
638
+ return result
639
+
640
+
641
+ def __post_data(url: str,
642
+ header_data: dict[str, str],
643
+ body_data: dict[str, Any],
644
+ errors: list[str] | None,
645
+ logger: Logger | None) -> None:
646
+ """
647
+ Submit a *POST* request to *url*.
648
+
649
+ :param header_data: the data to send in the header of the request
650
+ :param body_data: the data to send in the body of the request
651
+ :param errors: incidental errors
652
+ :param logger: optional logger
653
+ """
654
+ # log the POST
655
+ if logger:
656
+ logger.debug(msg=f"POST {url}, {json.dumps(obj=body_data,
657
+ ensure_ascii=False)}")
658
+ try:
659
+ response: requests.Response = requests.get(url=url,
660
+ headers=header_data,
661
+ data=body_data)
662
+ if response.status_code >= 400:
663
+ # request failed, report the problem
664
+ msg = f"POST failure, status {response.status_code}, reason {response.reason}"
665
+ if hasattr(response, "content") and response.content:
666
+ msg += f", content '{response.content}'"
667
+ if logger:
668
+ logger.error(msg=msg)
669
+ if isinstance(errors, list):
670
+ errors.append(msg)
671
+ elif logger:
672
+ logger.debug(msg=f"POST success")
673
+ except Exception as e:
674
+ # the operation raised an exception
675
+ msg = exc_format(exc=e,
676
+ exc_info=sys.exc_info())
677
+ if logger:
678
+ logger.error(msg=msg)
679
+ if isinstance(errors, list):
680
+ errors.append(msg)
681
+
682
+
683
+ def __post_for_token(iam_server: IamServer,
684
+ header_data: dict[str, str],
685
+ body_data: dict[str, Any],
686
+ errors: list[str] | None,
687
+ logger: Logger | None) -> dict[str, Any] | None:
688
+ """
689
+ Send a *POST* request to *iam_server* and return the authentication token data obtained.
690
+
691
+ For token acquisition, *body_data* will have the attributes:
692
+ - "grant_type": "authorization_code"
693
+ - "code": <16-character-random-code>
694
+ - "redirect_uri": <redirect-uri>
695
+
696
+ For token refresh, *body_data* will have the attributes:
697
+ - "grant_type": "refresh_token"
698
+ - "refresh_token": <current-refresh-token>
699
+
700
+ For token exchange, *body_data* will have the attributes:
701
+ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
702
+ - "subject_token": <token-to-be-exchanged>,
703
+ - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
704
+ - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
705
+ - "audience": <client-id>,
706
+ - "subject_issuer": "oidc"
707
+
708
+ For administrative token acquisition, *body_data* will have the attributes:
709
+ - "grant_type": "password"
710
+ - "username": <realm-administrator-identification>
711
+ - "password": <realm-administrator-secret>
712
+
713
+ These attributes are then added to *body_data*, except for acquiring administrative tokens:
714
+ - "client_id": <client-id>
715
+ - "client_secret": <client-secret>
716
+
717
+ If the operation is successful, the token data is stored in the *IAM* server's registry, and returned.
718
+ Otherwise, *errors* will contain the appropriate error message.
719
+
720
+ The typical data set returned contains the following attributes:
721
+ {
722
+ "token_type": "Bearer",
723
+ "access_token": <str>,
724
+ "expires_in": <number-of-seconds>,
725
+ "refresh_token": <str>,
726
+ "refesh_expires_in": <number-of-seconds>
727
+ }
728
+
729
+ :param iam_server: the reference registered *IAM* server
730
+ :param header_data: the data to send in the header of the request
731
+ :param body_data: the data to send in the body of the request
732
+ :param errors: incidental errors
733
+ :param logger: optional logger
734
+ :return: the token data, or *None* if error
735
+ """
736
+ # initialize the return variable
737
+ result: dict[str, Any] | None = None
738
+
739
+ err_msg: str | None = None
740
+ with _iam_lock:
741
+ # retrieve the IAM server's registry
742
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
743
+ errors=errors,
744
+ logger=logger)
745
+ if registry:
746
+ # complete the data to send in body of request
747
+ if body_data["grant_type"] != "password":
748
+ body_data["client_id"] = registry[IamParam.CLIENT_ID]
749
+
750
+ # build the URL
751
+ base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
752
+ url: str = f"{base_url}/protocol/openid-connect/token"
753
+ # 'client_secret' data must not be shown in log
754
+ msg: str = f"POST {url}, {json.dumps(obj=body_data,
755
+ ensure_ascii=False)}"
756
+ if body_data["grant_type"] != "password":
757
+ # 'client_secret' not required for requesting tokens from staging environments
758
+ client_secret: str = __get_client_secret(iam_server=iam_server,
759
+ errors=None,
760
+ logger=logger)
761
+ if client_secret:
762
+ body_data["client_secret"] = client_secret
763
+ # log the POST
764
+ if logger:
765
+ logger.debug(msg=msg)
766
+
767
+ # obtain the token
768
+ try:
769
+ # typical return on a token request:
770
+ # {
771
+ # "token_type": "Bearer",
772
+ # "access_token": <str>,
773
+ # "expires_in": <number-of-seconds>,
774
+ # "refresh_token": <str>,
775
+ # "refesh_expires_in": <number-of-seconds>
776
+ # }
777
+ response: requests.Response = requests.post(url=url,
778
+ headers=header_data,
779
+ data=body_data)
780
+ if response.status_code == 200:
781
+ # request succeeded
782
+ result = response.json()
783
+ if logger:
784
+ logger.debug(msg=f"POST success, {json.dumps(obj=result,
785
+ ensure_ascii=False)}")
786
+ else:
787
+ # request failed, report the problem
788
+ err_msg = f"POST failure, status {response.status_code}, reason {response.reason}"
789
+ if hasattr(response, "content") and response.content:
790
+ err_msg += f", content '{response.content}'"
791
+ if logger:
792
+ logger.error(msg=err_msg)
793
+ except Exception as e:
794
+ # the operation raised an exception
795
+ err_msg = exc_format(exc=e,
796
+ exc_info=sys.exc_info())
797
+ if logger:
798
+ logger.error(msg=err_msg)
799
+
800
+ if err_msg and isinstance(errors, list):
801
+ errors.append(err_msg)
802
+
803
+ return result
804
+
805
+
806
+ def __validate_and_store(iam_server: IamServer,
807
+ user_data: dict[str, Any],
808
+ token_data: dict[str, Any],
809
+ now: int,
810
+ errors: list[str] | None,
811
+ logger: Logger) -> tuple[str, str] | None:
812
+ """
813
+ Validate and store the token data.
814
+
815
+ The typical *token_data* contains the following attributes:
816
+ {
817
+ "token_type": "Bearer",
818
+ "access_token": <str>,
819
+ "expires_in": <number-of-seconds>,
820
+ "refresh_token": <str>,
821
+ "refesh_expires_in": <number-of-seconds>
822
+ }
823
+
824
+ :param iam_server: the reference registered *IAM* server
825
+ :param user_data: the aurthentication data kepth in *iam_server*'s registry
826
+ :param token_data: the token data
827
+ :param errors: incidental errors
828
+ :param logger: optional logger
829
+ :return: tuple containing the user identification and the validated and stored token, or *None* if error
830
+ """
831
+ # initialize the return variable
832
+ result: tuple[str, str] | None = None
833
+
834
+ with _iam_lock:
835
+ # retrieve the IAM server's registry
836
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
837
+ errors=errors,
838
+ logger=logger)
839
+ if registry:
840
+ token: str = token_data.get("access_token")
841
+ user_data["access-token"] = token
842
+ # keep current refresh token if a new one is not provided
843
+ if token_data.get("refresh_token"):
844
+ user_data["refresh-token"] = token_data.get("refresh_token")
845
+ user_data["access-expiration"] = now + token_data.get("expires_in")
846
+ refresh_exp: int = user_data.get("refresh_expires_in")
847
+ user_data["refresh-expiration"] = (now + refresh_exp) if refresh_exp else sys.maxsize
848
+ public_key: str = _get_public_key(iam_server=iam_server,
849
+ errors=errors,
850
+ logger=logger)
851
+ recipient_attr = registry[IamParam.RECIPIENT_ATTR]
852
+ login_id = user_data.pop("login-id", None)
853
+ base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
854
+ claims: dict[str, dict[str, Any]] = token_validate(token=token,
855
+ issuer=base_url,
856
+ recipient_id=login_id,
857
+ recipient_attr=recipient_attr,
858
+ public_key=public_key,
859
+ errors=errors,
860
+ logger=logger)
861
+ if claims:
862
+ users: dict[str, dict[str, Any]] = _get_iam_users(iam_server=iam_server,
863
+ errors=errors,
864
+ logger=logger)
865
+ # must test with 'not errors'
866
+ if not errors:
867
+ user_id: str = login_id if login_id else claims["payload"][recipient_attr]
868
+ users[user_id] = user_data
869
+ result = (user_id, token)
870
+ return result