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.
- pypomes_iam/__init__.py +20 -8
- pypomes_iam/iam_actions.py +878 -0
- pypomes_iam/iam_common.py +388 -0
- pypomes_iam/iam_pomes.py +137 -157
- pypomes_iam/iam_services.py +394 -0
- pypomes_iam/provider_pomes.py +175 -72
- pypomes_iam/token_pomes.py +63 -8
- {pypomes_iam-0.2.3.dist-info → pypomes_iam-0.7.0.dist-info}/METADATA +1 -2
- pypomes_iam-0.7.0.dist-info/RECORD +11 -0
- pypomes_iam/common_pomes.py +0 -397
- pypomes_iam/jusbr_pomes.py +0 -167
- pypomes_iam/keycloak_pomes.py +0 -170
- pypomes_iam-0.2.3.dist-info/RECORD +0 -11
- {pypomes_iam-0.2.3.dist-info → pypomes_iam-0.7.0.dist-info}/WHEEL +0 -0
- {pypomes_iam-0.2.3.dist-info → pypomes_iam-0.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import StrEnum, auto
|
|
5
|
+
from logging import Logger
|
|
6
|
+
from pypomes_core import TZ_LOCAL, exc_format
|
|
7
|
+
from pypomes_crypto import crypto_jwk_convert
|
|
8
|
+
from threading import RLock
|
|
9
|
+
from typing import Any, Final
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IamServer(StrEnum):
|
|
13
|
+
"""
|
|
14
|
+
Supported IAM servers.
|
|
15
|
+
"""
|
|
16
|
+
JUSBR = auto()
|
|
17
|
+
KEYCLOAK = auto()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IamParam(StrEnum):
|
|
21
|
+
"""
|
|
22
|
+
Parameters for configuring *IAM* servers.
|
|
23
|
+
"""
|
|
24
|
+
ADMIN_ID = "admin-id"
|
|
25
|
+
ADMIN_SECRET = "admin-secret"
|
|
26
|
+
CLIENT_ID = "client-id"
|
|
27
|
+
CLIENT_REALM = "client-realm"
|
|
28
|
+
CLIENT_SECRET = "client-secret"
|
|
29
|
+
ENDPOINT_CALLBACK = "endpoint-callback"
|
|
30
|
+
ENDPOINT_LOGIN = "endpoint-login"
|
|
31
|
+
ENDPOINT_LOGOUT = "endpoint_logout"
|
|
32
|
+
ENDPOINT_TOKEN = "endpoint-token"
|
|
33
|
+
ENDPOINT_EXCHANGE = "endpoint-exchange"
|
|
34
|
+
LOGIN_TIMEOUT = "login-timeout"
|
|
35
|
+
PK_EXPIRATION = "pk-expiration"
|
|
36
|
+
PK_LIFETIME = "pk-lifetime"
|
|
37
|
+
PUBLIC_KEY = "public-key"
|
|
38
|
+
RECIPIENT_ATTR = "recipient-attr"
|
|
39
|
+
URL_BASE = "url-base"
|
|
40
|
+
USERS = "users"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UserParam(StrEnum):
|
|
44
|
+
"""
|
|
45
|
+
Parameters for handling *IAM* users.
|
|
46
|
+
"""
|
|
47
|
+
ACCESS_TOKEN = "access-token"
|
|
48
|
+
REFRESH_TOKEN = "refresh-token"
|
|
49
|
+
ACCESS_EXPIRATION = "access-expiration"
|
|
50
|
+
REFRESH_EXPIRATION = "refresh-expiration"
|
|
51
|
+
# transient attributes
|
|
52
|
+
LOGIN_EXPIRATION = "login-expiration"
|
|
53
|
+
LOGIN_ID = "login-id"
|
|
54
|
+
REDIRECT_URI = "redirect-uri"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# The configuration parameters for the IAM servers are specified dynamically dynamically with *iam_setup()*
|
|
58
|
+
# Specifying configuration parameters with environment variables can be done in two ways:
|
|
59
|
+
#
|
|
60
|
+
# 1. for a single *IAM* server, specify the data set
|
|
61
|
+
# - *<APP_PREFIX>_IAM_ADMIN_ID* (optional, needed if administrative duties are performed)
|
|
62
|
+
# - *<APP_PREFIX>_IAM_ADMIN_PWD* (optional, needed if administrative duties are performed)
|
|
63
|
+
# - *<APP_PREFIX>_IAM_CLIENT_ID* (required)
|
|
64
|
+
# - *<APP_PREFIX>_IAM_CLIENT_REALM* (required)
|
|
65
|
+
# - *<APP_PREFIX>_IAM_CLIENT_SECRET* (required)
|
|
66
|
+
# - *<APP_PREFIX>_IAM_ENDPOINT_CALLBACK* (required)
|
|
67
|
+
# - *<APP_PREFIX>_IAM_ENDPOINT_EXCHANGE* (required)
|
|
68
|
+
# - *<APP_PREFIX>_IAM_ENDPOINT_LOGIN* (required)
|
|
69
|
+
# - *<APP_PREFIX>_IAM_ENDPOINT_LOGOUT* (required)
|
|
70
|
+
# - *<APP_PREFIX>_IAM_ENDPOINT_PROVIDER* (optional, needed if requesting tokens to providers)
|
|
71
|
+
# - *<APP_PREFIX>_IAM_ENDPOINT_TOKEN* (required)
|
|
72
|
+
# - *<APP_PREFIX>_IAM_LOGIN_TIMEOUT* (optional, defaults to no timeout)
|
|
73
|
+
# - *<APP_PREFIX>_IAM_PK_LIFETIME* (optional, defaults to non-terminating lifetime)
|
|
74
|
+
# - *<APP_PREFIX>_IAM_RECIPIENT_ATTR* (required)
|
|
75
|
+
# - *<APP_PREFIX>_IAM_URL_BASE* (required)
|
|
76
|
+
#
|
|
77
|
+
# 2. for multiple *IAM* servers, specify the data set above for each server,
|
|
78
|
+
# respectively replacing *IAM* with a name in *IamServer* (currently, *JUSBR* and *KEYCLOAK* are supported).
|
|
79
|
+
#
|
|
80
|
+
# 3. the parameters *PUBLIC_KEY*, *PK_EXPIRATION*, and *USERS* cannot be assigned values,
|
|
81
|
+
# as they are reserved for internal use
|
|
82
|
+
|
|
83
|
+
# registry structure:
|
|
84
|
+
# { <IamServer>:
|
|
85
|
+
# {
|
|
86
|
+
# "base-url": <str>,
|
|
87
|
+
# "admin-id": <str>,
|
|
88
|
+
# "admin-secret": <str>,
|
|
89
|
+
# "client-id": <str>,
|
|
90
|
+
# "client-secret": <str>,
|
|
91
|
+
# "client-realm": <str,
|
|
92
|
+
# "client-timeout": <int>,
|
|
93
|
+
# "recipient-attr": <str>,
|
|
94
|
+
# "public-key": <str>,
|
|
95
|
+
# "pk-lifetime": <int>,
|
|
96
|
+
# "pk-expiration": <int>,
|
|
97
|
+
# "users": {}
|
|
98
|
+
# },
|
|
99
|
+
# ...
|
|
100
|
+
# }
|
|
101
|
+
# data in "users":
|
|
102
|
+
# {
|
|
103
|
+
# "<user-id>": {
|
|
104
|
+
# "access-token": <str>
|
|
105
|
+
# "refresh-token": <str>
|
|
106
|
+
# "access-expiration": <timestamp>,
|
|
107
|
+
# "refresh-expiration": <timestamp>,
|
|
108
|
+
# # transient attributes
|
|
109
|
+
# "login-expiration": <timestamp>,
|
|
110
|
+
# "login-id": <str>,
|
|
111
|
+
# "redirect-uri": <str>
|
|
112
|
+
# },
|
|
113
|
+
# ...
|
|
114
|
+
# }
|
|
115
|
+
_IAM_SERVERS: Final[dict[IamServer, dict[IamParam, Any]]] = {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# the lock protecting the data in '_IAM_SERVERS'
|
|
119
|
+
# (because it is 'Final' and set at declaration time, it can be accessed through simple imports)
|
|
120
|
+
_iam_lock: Final[RLock] = RLock()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _iam_server_from_endpoint(endpoint: str,
|
|
124
|
+
errors: list[str] | None,
|
|
125
|
+
logger: Logger | None) -> IamServer | None:
|
|
126
|
+
"""
|
|
127
|
+
Retrieve the registered *IAM* server associated with the service's invocation *endpoint*.
|
|
128
|
+
|
|
129
|
+
:param endpoint: the service's invocation endpoint
|
|
130
|
+
:param errors: incidental error messages
|
|
131
|
+
:param logger: optional logger
|
|
132
|
+
:return: the corresponding *IAM* server, or *None* if one could not be obtained
|
|
133
|
+
"""
|
|
134
|
+
# initialize the return variable
|
|
135
|
+
result: IamServer | None = None
|
|
136
|
+
|
|
137
|
+
for iam_server in _IAM_SERVERS:
|
|
138
|
+
if endpoint.startswith(iam_server):
|
|
139
|
+
result = iam_server
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
if not result:
|
|
143
|
+
msg: str = f"Unable to find a IAM server to service endpoint '{endpoint}'"
|
|
144
|
+
if logger:
|
|
145
|
+
logger.error(msg=msg)
|
|
146
|
+
if isinstance(errors, list):
|
|
147
|
+
errors.append(msg)
|
|
148
|
+
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _iam_server_from_issuer(issuer: str,
|
|
153
|
+
errors: list[str] | None,
|
|
154
|
+
logger: Logger | None) -> IamServer | None:
|
|
155
|
+
"""
|
|
156
|
+
Retrieve the registered *IAM* server associated with the token's *issuer*.
|
|
157
|
+
|
|
158
|
+
:param issuer: the token's issuer
|
|
159
|
+
:param errors: incidental error messages
|
|
160
|
+
:param logger: optional logger
|
|
161
|
+
:return: the corresponding *IAM* server, or *None* if one could not be obtained
|
|
162
|
+
"""
|
|
163
|
+
# initialize the return variable
|
|
164
|
+
result: IamServer | None = None
|
|
165
|
+
|
|
166
|
+
for iam_server, registry in _IAM_SERVERS.items():
|
|
167
|
+
base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
|
|
168
|
+
if base_url == issuer:
|
|
169
|
+
result = IamServer(iam_server)
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
if not result:
|
|
173
|
+
msg: str = f"Unable to find a IAM server associated with token issuer '{issuer}'"
|
|
174
|
+
if logger:
|
|
175
|
+
logger.error(msg=msg)
|
|
176
|
+
if isinstance(errors, list):
|
|
177
|
+
errors.append(msg)
|
|
178
|
+
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _get_public_key(iam_server: IamServer,
|
|
183
|
+
errors: list[str] | None,
|
|
184
|
+
logger: Logger | None) -> str:
|
|
185
|
+
"""
|
|
186
|
+
Obtain the public key used by *iam_server* to sign the authentication tokens.
|
|
187
|
+
|
|
188
|
+
This is accomplished by requesting the token issuer for its *JWKS* (JSON Web Key Set),
|
|
189
|
+
containing the public keys used for various purposes, as indicated in the attribute *use*:
|
|
190
|
+
- *enc*: the key is intended for encryption
|
|
191
|
+
- *sig*: the key is intended for digital signature
|
|
192
|
+
- *wrap*: the key is intended for key wrapping
|
|
193
|
+
|
|
194
|
+
A typical JWKS set has the following format (for simplicity, 'n' and 'x5c' are truncated):
|
|
195
|
+
{
|
|
196
|
+
"keys": [
|
|
197
|
+
{
|
|
198
|
+
"kid": "X2QEcSQ4Tg2M2EK6s2nhRHZH_GwD_zxZtiWVwP4S0tg",
|
|
199
|
+
"kty": "RSA",
|
|
200
|
+
"alg": "RSA256",
|
|
201
|
+
"use": "sig",
|
|
202
|
+
"n": "tQmDmyM3tMFt5FMVMbqbQYpaDPf6A5l4e_kTVDBiHrK_bRlGfkk8hYm5SNzNzCZ...",
|
|
203
|
+
"e": "AQAB",
|
|
204
|
+
"x5c": [
|
|
205
|
+
"MIIClzCCAX8CBgGZY0bqrTANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDARpanVk..."
|
|
206
|
+
],
|
|
207
|
+
"x5t": "MHfVp4kBjEZuYOtiaaGsfLCL15Q",
|
|
208
|
+
"x5t#S256": "QADezSLgD8emuonBz8hn8ghTnxo7AHX4NVNkr4luEhk"
|
|
209
|
+
},
|
|
210
|
+
...
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
Once the signature key is obtained, it is converted from its original *JWK* (JSON Web Key) format
|
|
215
|
+
to *PEM* (Privacy-Enhanced Mail) format. The public key is saved in *iam_server*'s registry.
|
|
216
|
+
|
|
217
|
+
:param iam_server: the reference registered *IAM* server
|
|
218
|
+
:param errors: incidental error messages
|
|
219
|
+
:param logger: optional logger
|
|
220
|
+
:return: the public key in *PEM* format, or *None* if error
|
|
221
|
+
"""
|
|
222
|
+
# initialize the return variable
|
|
223
|
+
result: str | None = None
|
|
224
|
+
|
|
225
|
+
registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
|
|
226
|
+
errors=errors,
|
|
227
|
+
logger=logger)
|
|
228
|
+
if registry:
|
|
229
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
230
|
+
if now > registry[IamParam.PK_EXPIRATION]:
|
|
231
|
+
# obtain the JWKS (JSON Web Key Set) from the token issuer
|
|
232
|
+
base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
|
|
233
|
+
url: str = f"{base_url}/protocol/openid-connect/certs"
|
|
234
|
+
if logger:
|
|
235
|
+
logger.debug(msg=f"Obtaining signature public key used by IAM server '{iam_server}'")
|
|
236
|
+
logger.debug(msg=f"GET {url}")
|
|
237
|
+
try:
|
|
238
|
+
response: requests.Response = requests.get(url=url)
|
|
239
|
+
if response.status_code == 200:
|
|
240
|
+
# request succeeded
|
|
241
|
+
if logger:
|
|
242
|
+
logger.debug(msg=f"GET success, status {response.status_code}")
|
|
243
|
+
# select the appropriate JWK
|
|
244
|
+
reply: dict[str, list[dict[str, str]]] = response.json()
|
|
245
|
+
jwk: dict[str, str] | None = None
|
|
246
|
+
for key in reply["keys"]:
|
|
247
|
+
if key.get("use") == "sig":
|
|
248
|
+
jwk = key
|
|
249
|
+
break
|
|
250
|
+
if jwk:
|
|
251
|
+
# convert from 'JWK' to 'PEM' and save it for further use
|
|
252
|
+
result = crypto_jwk_convert(jwk=jwk,
|
|
253
|
+
fmt="PEM")
|
|
254
|
+
registry[IamParam.PUBLIC_KEY] = result
|
|
255
|
+
lifetime: int = registry[IamParam.PK_LIFETIME] or 0
|
|
256
|
+
registry[IamParam.PK_EXPIRATION] = now + lifetime if lifetime else sys.maxsize
|
|
257
|
+
if logger:
|
|
258
|
+
logger.debug("Public key obtained and saved")
|
|
259
|
+
else:
|
|
260
|
+
msg = "Signature public key missing from the token issuer's JWKS"
|
|
261
|
+
if logger:
|
|
262
|
+
logger.error(msg=msg)
|
|
263
|
+
if isinstance(errors, list):
|
|
264
|
+
errors.append(msg)
|
|
265
|
+
elif logger:
|
|
266
|
+
msg: str = f"GET failure, status {response.status_code}, reason {response.reason}"
|
|
267
|
+
if hasattr(response, "content") and response.content:
|
|
268
|
+
msg += f", content {response.content}"
|
|
269
|
+
logger.error(msg=msg)
|
|
270
|
+
if isinstance(errors, list):
|
|
271
|
+
errors.append(msg)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
# the operation raised an exception
|
|
274
|
+
msg = exc_format(exc=e,
|
|
275
|
+
exc_info=sys.exc_info())
|
|
276
|
+
if logger:
|
|
277
|
+
logger.error(msg=msg)
|
|
278
|
+
if isinstance(errors, list):
|
|
279
|
+
errors.append(msg)
|
|
280
|
+
else:
|
|
281
|
+
result = registry[IamParam.PUBLIC_KEY]
|
|
282
|
+
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _get_login_timeout(iam_server: IamServer,
|
|
287
|
+
errors: list[str] | None,
|
|
288
|
+
logger: Logger) -> int | None:
|
|
289
|
+
"""
|
|
290
|
+
Retrieve the timeout currently applicable for the login operation.
|
|
291
|
+
|
|
292
|
+
:param iam_server: the reference registered *IAM* server
|
|
293
|
+
:param errors: incidental error messages
|
|
294
|
+
:param logger: optional logger
|
|
295
|
+
:return: the current login timeout, or *None* if the server is unknown or none has been set.
|
|
296
|
+
"""
|
|
297
|
+
# initialize the return variable
|
|
298
|
+
result: int | None = None
|
|
299
|
+
|
|
300
|
+
registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
|
|
301
|
+
errors=errors,
|
|
302
|
+
logger=logger)
|
|
303
|
+
if registry:
|
|
304
|
+
timeout: int = registry.get("client-timeout")
|
|
305
|
+
if isinstance(timeout, int) and timeout > 0:
|
|
306
|
+
result = timeout
|
|
307
|
+
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _get_user_data(iam_server: IamServer,
|
|
312
|
+
user_id: str,
|
|
313
|
+
errors: list[str] | None,
|
|
314
|
+
logger: Logger | None) -> dict[str, Any] | None:
|
|
315
|
+
"""
|
|
316
|
+
Retrieve the data for *user_id* from *iam_server*'s registry.
|
|
317
|
+
|
|
318
|
+
If an entry is not found for *user_id* in the registry, it is created.
|
|
319
|
+
It will remain there until the user is logged out.
|
|
320
|
+
|
|
321
|
+
:param iam_server: the reference registered *IAM* server
|
|
322
|
+
:param errors: incidental error messages
|
|
323
|
+
:param logger: optional logger
|
|
324
|
+
:return: the data for *user_id* in *iam_server*'s registry, or *None* if the server is unknown
|
|
325
|
+
"""
|
|
326
|
+
# initialize the return variable
|
|
327
|
+
result: dict[str, Any] | None = None
|
|
328
|
+
|
|
329
|
+
users: dict[str, dict[str, Any]] = _get_iam_users(iam_server=iam_server,
|
|
330
|
+
errors=errors,
|
|
331
|
+
logger=logger)
|
|
332
|
+
if isinstance(users, dict):
|
|
333
|
+
result = users.get(user_id)
|
|
334
|
+
if not result:
|
|
335
|
+
result = {
|
|
336
|
+
UserParam.ACCESS_TOKEN: None,
|
|
337
|
+
UserParam.REFRESH_TOKEN: None,
|
|
338
|
+
UserParam.ACCESS_EXPIRATION: int(datetime.now(tz=TZ_LOCAL).timestamp()),
|
|
339
|
+
UserParam.REFRESH_EXPIRATION: sys.maxsize
|
|
340
|
+
}
|
|
341
|
+
users[user_id] = result
|
|
342
|
+
if logger:
|
|
343
|
+
logger.debug(msg=f"Entry for '{user_id}' added to {iam_server}'s registry")
|
|
344
|
+
elif logger:
|
|
345
|
+
logger.debug(msg=f"Entry for '{user_id}' obtained from {iam_server}'s registry")
|
|
346
|
+
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _get_iam_registry(iam_server: IamServer,
|
|
351
|
+
errors: list[str] | None,
|
|
352
|
+
logger: Logger | None) -> dict[str, Any]:
|
|
353
|
+
"""
|
|
354
|
+
Retrieve the registry associated with *iam_server*.
|
|
355
|
+
|
|
356
|
+
:param iam_server: the reference registered *IAM* server
|
|
357
|
+
:param errors: incidental error messages
|
|
358
|
+
:param logger: optional logger
|
|
359
|
+
:return: the registry associated with *iam_server*, or *None* if the server is unknown
|
|
360
|
+
"""
|
|
361
|
+
# assign the return variable
|
|
362
|
+
result: dict[str, Any] = _IAM_SERVERS.get(iam_server)
|
|
363
|
+
|
|
364
|
+
if not result:
|
|
365
|
+
msg = f"Unknown IAM server '{iam_server}'"
|
|
366
|
+
if logger:
|
|
367
|
+
logger.error(msg=msg)
|
|
368
|
+
if isinstance(errors, list):
|
|
369
|
+
errors.append(msg)
|
|
370
|
+
|
|
371
|
+
return result
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _get_iam_users(iam_server: IamServer,
|
|
375
|
+
errors: list[str] | None,
|
|
376
|
+
logger: Logger | None) -> dict[str, dict[str, Any]]:
|
|
377
|
+
"""
|
|
378
|
+
Retrieve the users data storage in *iam_server*'s registry.
|
|
379
|
+
|
|
380
|
+
:param iam_server: the reference registered *IAM* server
|
|
381
|
+
:param errors: incidental error messages
|
|
382
|
+
:param logger: optional logger
|
|
383
|
+
:return: the users data storage in *iam_server*'s registry, or *None* if the server is unknown
|
|
384
|
+
"""
|
|
385
|
+
registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
|
|
386
|
+
errors=errors,
|
|
387
|
+
logger=logger)
|
|
388
|
+
return registry[IamParam.USERS] if registry else None
|