pypomes-iam 0.3.1__tar.gz → 0.3.3__tar.gz

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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_iam
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A collection of Python pomes, penyeach (IAM modules)
5
5
  Project-URL: Homepage, https://github.com/TheWiseCoder/PyPomes-IAM
6
6
  Project-URL: Bug Tracker, https://github.com/TheWiseCoder/PyPomes-IAM/issues
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "pypomes_iam"
9
- version = "0.3.1"
9
+ version = "0.3.3"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -1,3 +1,8 @@
1
+ from .iam_pomes import (
2
+ IamServer, register_logger,
3
+ login_callback, token_exchange,
4
+ user_login, user_logout, user_token
5
+ )
1
6
  from .jusbr_pomes import (
2
7
  jusbr_setup, jusbr_get_token
3
8
  )
@@ -12,6 +17,10 @@ from .token_pomes import (
12
17
  )
13
18
 
14
19
  __all__ = [
20
+ # iam_pomes
21
+ "register_logger",
22
+ "login_callback", "token_exchange",
23
+ "user_login", "user_logout", "user_token",
15
24
  # jusbr_pomes
16
25
  "jusbr_setup", "jusbr_get_token",
17
26
  # keycloak_pomes
@@ -0,0 +1,366 @@
1
+ import json
2
+ import requests
3
+ import sys
4
+ from cachetools import Cache
5
+ from datetime import datetime
6
+ from enum import StrEnum
7
+ from logging import Logger
8
+ from pypomes_core import TZ_LOCAL, exc_format
9
+ from pypomes_crypto import crypto_jwk_convert
10
+ from typing import Any, Final
11
+
12
+
13
+ class IamServer(StrEnum):
14
+ """
15
+ Supported IAM servers.
16
+ """
17
+ IAM_JUSRBR = "iam-jusbr",
18
+ IAM_KEYCLOAK = "iam-keycloak"
19
+
20
+
21
+ # the logger for IAM operations
22
+ __IAM_LOGGER: Logger | None = None
23
+
24
+ # registry structure:
25
+ # { <IamServer>:
26
+ # {
27
+ # "client-id": <str>,
28
+ # "client-secret": <str>,
29
+ # "client-timeout": <int>,
30
+ # "recipient-attr": <str>,
31
+ # "public_key": <str>,
32
+ # "pk-lifetime": <int>,
33
+ # "pk-expiration": <int>,
34
+ # "base-url": <str>,
35
+ # "cache": <FIFOCache>,
36
+ # "redirect-uri": <str> <-- transient
37
+ # },
38
+ # ...
39
+ # }
40
+ # data in "cache":
41
+ # {
42
+ # "users": {
43
+ # "<user-id>": {
44
+ # "access-token": <str>
45
+ # "refresh-token": <str>
46
+ # "access-expiration": <timestamp>,
47
+ # "refresh-expiration": <timestamp>,
48
+ # "login-expiration": <timestamp>, <-- transient
49
+ # "login-id": <str>, <-- transient
50
+ # }
51
+ # },
52
+ # ...
53
+ # }
54
+ _IAM_SERVERS: Final[dict[IamServer, dict[str, Any]]] = {}
55
+
56
+
57
+ def _get_logger() -> Logger | None:
58
+ """
59
+ Retrieve the registered logger for *IAM* operations.
60
+
61
+ :return: the registered logger for *IAM* operations.
62
+ """
63
+ return __IAM_LOGGER
64
+
65
+
66
+ def _register_logger(logger: Logger) -> None:
67
+ """
68
+ Register the logger for *IAM* operations
69
+
70
+ :param logger: the logger to be rergistered
71
+ """
72
+ global __IAM_LOGGER
73
+ __IAM_LOGGER = logger
74
+
75
+
76
+ def _get_public_key(iam_server: IamServer,
77
+ errors: list[str] | None,
78
+ logger: Logger | None) -> str:
79
+ """
80
+ Obtain the public key used by *iam_server* to sign the authentication tokens.
81
+
82
+ The public key is saved in *iam_server*'s registry.
83
+
84
+ :param iam_server: the reference registered *IAM* server
85
+ :param errors: incidental error messages
86
+ :param logger: optional logger
87
+ :return: the public key in *PEM* format, or *None* if the server is unknown
88
+ """
89
+ # initialize the return variable
90
+ result: str | None = None
91
+
92
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
93
+ errors=errors,
94
+ logger=logger)
95
+ if registry:
96
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
97
+ if now > registry["pk-expiration"]:
98
+ # obtain a new public key
99
+ url: str = f"{registry["base-url"]}/protocol/openid-connect/certs"
100
+ if logger:
101
+ logger.debug(msg=f"GET '{url}'")
102
+ try:
103
+ response: requests.Response = requests.get(url=url)
104
+ if response.status_code == 200:
105
+ # request succeeded
106
+ if logger:
107
+ logger.debug(msg=f"GET success, status {response.status_code}")
108
+ reply: dict[str, Any] = response.json()
109
+ result = crypto_jwk_convert(jwk=reply["keys"][0],
110
+ fmt="PEM")
111
+ registry["public-key"] = result
112
+ lifetime: int = registry["pk-lifetime"] or 0
113
+ registry["pk-expiration"] = now + lifetime
114
+ elif logger:
115
+ msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
116
+ if hasattr(response, "content") and response.content:
117
+ msg += f", content '{response.content}'"
118
+ logger.error(msg=msg)
119
+ if isinstance(errors, list):
120
+ errors.append(msg)
121
+ except Exception as e:
122
+ # the operation raised an exception
123
+ msg = exc_format(exc=e,
124
+ exc_info=sys.exc_info())
125
+ if logger:
126
+ logger.error(msg=msg)
127
+ if isinstance(errors, list):
128
+ errors.append(msg)
129
+ else:
130
+ result = registry["public-key"]
131
+
132
+ return result
133
+
134
+
135
+ def _get_login_timeout(iam_server: IamServer,
136
+ errors: list[str] | None,
137
+ logger: Logger) -> int | None:
138
+ """
139
+ Retrieve the timeout currently applicable for the login operation.
140
+
141
+ :param iam_server: the reference registered *IAM* server
142
+ :param errors: incidental error messages
143
+ :param logger: optional logger
144
+ :return: the current login timeout, or *None* if the server is unknown or none has been set.
145
+ """
146
+ # initialize the return variable
147
+ result: int | None = None
148
+
149
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
150
+ errors=errors,
151
+ logger=logger)
152
+ if registry:
153
+ timeout: int = registry.get("client-timeout")
154
+ if isinstance(timeout, int) and timeout > 0:
155
+ result = timeout
156
+
157
+ return result
158
+
159
+
160
+ def _get_user_data(iam_server: IamServer,
161
+ user_id: str,
162
+ errors: list[str] | None,
163
+ logger: Logger | None) -> dict[str, Any] | None:
164
+ """
165
+ Retrieve the data for *user_id* from *iam_server*'s registry.
166
+
167
+ If an entry is not found for *user_id* in the registry, it is created.
168
+ It will remain there until the user is logged out.
169
+
170
+ :param iam_server: the reference registered *IAM* server
171
+ :param errors: incidental error messages
172
+ :param logger: optional logger
173
+ :return: the data for *user_id* in *iam_server*'s registry, or *None* if the server is unknown
174
+ """
175
+ # initialize the return variable
176
+ result: dict[str, Any] | None = None
177
+
178
+ cache: Cache = _get_iam_cache(iam_server=iam_server,
179
+ errors=errors,
180
+ logger=logger)
181
+ if cache:
182
+ users: dict[str, dict[str, Any]] = cache.get("users")
183
+ result = users.get(user_id)
184
+ if not result:
185
+ result = {
186
+ "access-token": None,
187
+ "refresh-token": None,
188
+ "access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
189
+ "refresh-expiration": sys.maxsize
190
+ }
191
+ users[user_id] = result
192
+ if logger:
193
+ logger.debug(msg=f"Entry for '{user_id}' added to {iam_server}'s registry")
194
+ elif logger:
195
+ logger.debug(msg=f"Entry for '{user_id}' obtained from {iam_server}'s registry")
196
+
197
+ return result
198
+
199
+
200
+ def _get_iam_server(endpoint: str,
201
+ errors: list[str] | None,
202
+ logger: Logger | None) -> IamServer | None:
203
+ """
204
+ Retrieve the registered *IAM* server associated with the service's invocation *endpoint*.
205
+
206
+ :param endpoint: the service's invocation endpoint
207
+ :param errors: incidental error messages
208
+ :param logger: optional logger
209
+ :return: the corresponding *IAM* server, or *None* if one could not be obtained
210
+ """
211
+ # declare the return variable
212
+ result: IamServer | None
213
+
214
+ if endpoint.startswith("jusbr"):
215
+ result = IamServer.IAM_JUSRBR
216
+ elif endpoint.startswith("keycloak"):
217
+ result = IamServer.IAM_KEYCLOAK
218
+ else:
219
+ result = None
220
+ msg: str = f"Unknown endpoind {endpoint}"
221
+ if logger:
222
+ logger.error(msg=msg)
223
+ if isinstance(errors, list):
224
+ errors.append(msg)
225
+
226
+ return result
227
+
228
+
229
+ def _get_iam_registry(iam_server: IamServer,
230
+ errors: list[str] | None,
231
+ logger: Logger | None) -> dict[str, Any]:
232
+ """
233
+ Retrieve the registry associated with *iam_server*.
234
+
235
+ :param iam_server: the reference registered *IAM* server
236
+ :param errors: incidental error messages
237
+ :param logger: optional logger
238
+ :return: the registry associated with *iam_server*, or *None* if the server is unknown
239
+ """
240
+ # declare the return variable
241
+ result: dict[str, Any] | None
242
+
243
+ match iam_server:
244
+ case IamServer.IAM_JUSRBR:
245
+ result = _IAM_SERVERS[IamServer.IAM_JUSRBR]
246
+ case IamServer.IAM_KEYCLOAK:
247
+ result = _IAM_SERVERS[IamServer.IAM_KEYCLOAK]
248
+ case _:
249
+ result = None
250
+ msg = f"Unknown IAM server '{iam_server}'"
251
+ if logger:
252
+ logger.error(msg=msg)
253
+ if isinstance(errors, list):
254
+ errors.append(msg)
255
+
256
+ return result
257
+
258
+
259
+ def _get_iam_cache(iam_server: IamServer,
260
+ errors: list[str] | None,
261
+ logger: Logger | None) -> Cache:
262
+ """
263
+ Retrieve the cache storage in *iam_server*'s registry.
264
+
265
+ :param iam_server: the reference registered *IAM* server
266
+ :param errors: incidental error messages
267
+ :param logger: optional logger
268
+ :return: the cache storage in *iam_server*'s registry, or *None* if the server is unknown
269
+ """
270
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
271
+ errors=errors,
272
+ logger=logger)
273
+ return registry["cache"] if registry else None
274
+
275
+
276
+ def _post_for_token(iam_server: IamServer,
277
+ body_data: dict[str, Any],
278
+ errors: list[str] | None,
279
+ logger: Logger | None) -> dict[str, Any] | None:
280
+ """
281
+ Send a POST request to obtain the authentication token data, and return the data received.
282
+
283
+ For token acquisition, *body_data* will have the attributes:
284
+ - "grant_type": "authorization_code"
285
+ - "code": <16-character-random-code>
286
+ - "redirect_uri": <redirect-uri>
287
+
288
+ For token refresh, *body_data* will have the attributes:
289
+ - "grant_type": "refresh_token"
290
+ - "refresh_token": <current-refresh-token>
291
+
292
+ For token exchange, *body_data* will have the attributes:
293
+ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
294
+ - "subject_token": <token-to-be-exchanged>,
295
+ - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
296
+ - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
297
+ - "audience": <client-id>,
298
+ - "subject_issuer": "oidc"
299
+
300
+ These attributes are then added to *body_data*:
301
+ - "client_id": <client-id>,
302
+ - "client_secret": <client-secret>,
303
+
304
+ If the operation is successful, the token data is stored in the registry.
305
+ Otherwise, *errors* will contain the appropriate error message.
306
+
307
+ :param iam_server: the reference registered *IAM* server
308
+ :param body_data: the data to send in the body of the request
309
+ :param errors: incidental errors
310
+ :param logger: optional logger
311
+ :return: the access token obtained, or *None* if error
312
+ """
313
+ # initialize the return variable
314
+ result: dict[str, Any] | None = None
315
+
316
+ # PBTAIN THE iam SERVER'S REGISTRY
317
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
318
+ errors=errors,
319
+ logger=logger)
320
+ err_msg: str | None = None
321
+ if registry:
322
+ # complete the data to send in body of request
323
+ body_data["client_id"] = registry["client-id"]
324
+ client_secret: str = registry["client-secret"]
325
+ if client_secret:
326
+ body_data["client_secret"] = client_secret
327
+
328
+ # obtain the token
329
+ url: str = registry["base-url"] + "/protocol/openid-connect/token"
330
+ if logger:
331
+ logger.debug(msg=f"POST '{url}', data {json.dumps(obj=body_data,
332
+ ensure_ascii=False)}")
333
+ try:
334
+ # typical return on a token request:
335
+ # {
336
+ # "token_type": "Bearer",
337
+ # "access_token": <str>,
338
+ # "expires_in": <number-of-seconds>,
339
+ # "refresh_token": <str>,
340
+ # "refesh_expires_in": <number-of-seconds>
341
+ # }
342
+ response: requests.Response = requests.post(url=url,
343
+ data=body_data)
344
+ if response.status_code == 200:
345
+ # request succeeded
346
+ if logger:
347
+ logger.debug(msg=f"POST success, status {response.status_code}")
348
+ result = response.json()
349
+ else:
350
+ # request resulted in error
351
+ err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
352
+ if hasattr(response, "content") and response.content:
353
+ err_msg += f", content '{response.content}'"
354
+ if logger:
355
+ logger.error(msg=err_msg)
356
+ except Exception as e:
357
+ # the operation raised an exception
358
+ err_msg = exc_format(exc=e,
359
+ exc_info=sys.exc_info())
360
+ if logger:
361
+ logger.error(msg=err_msg)
362
+
363
+ if err_msg and isinstance(errors, list):
364
+ errors.append(err_msg)
365
+
366
+ return result