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