pypomes-iam 0.1.7__tar.gz → 0.1.9__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.1.7
3
+ Version: 0.1.9
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
@@ -13,6 +13,6 @@ Requires-Python: >=3.12
13
13
  Requires-Dist: cachetools>=6.2.1
14
14
  Requires-Dist: flask>=3.1.2
15
15
  Requires-Dist: pyjwt>=2.10.1
16
- Requires-Dist: pypomes-core>=2.8.0
16
+ Requires-Dist: pypomes-core>=2.8.1
17
17
  Requires-Dist: pypomes-crypto>=0.4.8
18
18
  Requires-Dist: requests>=2.32.5
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "pypomes_iam"
9
- version = "0.1.7"
9
+ version = "0.1.9"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -22,7 +22,7 @@ dependencies = [
22
22
  "cachetools>=6.2.1",
23
23
  "Flask>=3.1.2",
24
24
  "PyJWT>=2.10.1",
25
- "pypomes-core>=2.8.0",
25
+ "pypomes-core>=2.8.1",
26
26
  "pypomes-crypto>=0.4.8",
27
27
  "requests>=2.32.5"
28
28
  ]
@@ -1,6 +1,9 @@
1
1
  from .jusbr_pomes import (
2
2
  jusbr_setup, jusbr_get_token, jusbr_set_scope
3
3
  )
4
+ from .keycloak_pomes import (
5
+ keycloak_setup, keycloak_get_token, keycloak_set_scope
6
+ )
4
7
  from .provider_pomes import (
5
8
  provider_register, provider_get_token
6
9
  )
@@ -11,6 +14,8 @@ from .token_pomes import (
11
14
  __all__ = [
12
15
  # jusbr_pomes
13
16
  "jusbr_setup", "jusbr_get_token", "jusbr_set_scope",
17
+ # keycloak_pomes
18
+ "keycloak_setup", "keycloak_get_token", "keycloak_set_scope",
14
19
  # provider_pomes
15
20
  "provider_register", "provider_get_token",
16
21
  # token_pomes
@@ -0,0 +1,423 @@
1
+ import json
2
+ import requests
3
+ import secrets
4
+ import string
5
+ import sys
6
+ from cachetools import Cache
7
+ from datetime import datetime
8
+ from flask import Request
9
+ from logging import Logger
10
+ from pypomes_core import TZ_LOCAL, exc_format
11
+ from typing import Any
12
+
13
+ # registry structure:
14
+ # {
15
+ # "client-id": <str>,
16
+ # "client-secret": <str>,
17
+ # "client-timeout": <int>,
18
+ # "public_key": <str>,
19
+ # "key-lifetime": <int>,
20
+ # "key-expiration": <int>,
21
+ # "base-url": <str>,
22
+ # "callback-url": <str>,
23
+ # "safe-cache": <FIFOCache>
24
+ # }
25
+ # data in "safe-cache":
26
+ # {
27
+ # "users": {
28
+ # "<user-id>": {
29
+ # "access-token": <str>
30
+ # "refresh-token": <str>
31
+ # "access-expiration": <timestamp>,
32
+ # "login-expiration": <timestamp>, <-- transient
33
+ # "login-id": <str>, <-- transient
34
+ # "oauth-scope": <str> <-- optional
35
+ # }
36
+ # }
37
+ # }
38
+
39
+
40
+ def _service_callback(registry: dict[str, Any],
41
+ args: dict[str, Any],
42
+ errors: list[str],
43
+ logger: Logger | None) -> tuple[str, str]:
44
+ """
45
+ Entry point for the callback from JusBR on authentication operation.
46
+
47
+ :param registry: the registry holding the authentication data
48
+ :param args: the arguments passed when requesting the service
49
+ :param errors: incidental errors
50
+ :param logger: optional logger
51
+ """
52
+ from .token_pomes import token_validate
53
+
54
+ # initialize the return variable
55
+ result: tuple[str, str] | None = None
56
+
57
+ # retrieve the users authentication data
58
+ cache: Cache = registry["safe-cache"]
59
+ users: dict[str, dict[str, Any]] = cache.get("users")
60
+
61
+ # validate the OAuth2 state
62
+ oauth_state: str = args.get("state")
63
+ user_data: dict[str, Any] | None = None
64
+ if oauth_state:
65
+ for user, data in users.items():
66
+ if user == oauth_state:
67
+ user_data = data
68
+ break
69
+
70
+ # exchange 'code' for the token
71
+ if user_data:
72
+ users.pop(oauth_state)
73
+ code: str = args.get("code")
74
+ body_data: dict[str, Any] = {
75
+ "grant_type": "authorization_code",
76
+ "code": code,
77
+ "redirect_uri": registry.get("callback-url"),
78
+ }
79
+ token = _post_for_token(registry=registry,
80
+ user_data=user_data,
81
+ body_data=body_data,
82
+ errors=errors,
83
+ logger=logger)
84
+ # retrieve the token's claims
85
+ if not errors:
86
+ public_key: bytes = _get_public_key(registry=registry,
87
+ logger=logger)
88
+ token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
89
+ issuer=registry["base-url"],
90
+ public_key=public_key,
91
+ errors=errors,
92
+ logger=logger)
93
+ if not errors:
94
+ token_user: str = token_claims["payload"].get("preferred_username")
95
+ if token_user == oauth_state:
96
+ users[token_user] = user_data
97
+ result = (token_user, token)
98
+ else:
99
+ errors.append(f"Token was issued to user '{token_user}'")
100
+ else:
101
+ msg: str = "Unknown OAuth2 code received"
102
+ if _get_login_timeout(registry=registry):
103
+ msg += " - possible operation timeout"
104
+ errors.append(msg)
105
+
106
+ return result
107
+
108
+
109
+ def _service_login(registry: dict[str, Any],
110
+ args: dict[str, Any],
111
+ logger: Logger | None) -> str:
112
+ """
113
+ Build the callback URL for redirecting the request to the IAM's authentication page.
114
+
115
+ :param registry: the registry holding the authentication data
116
+ :param args: the arguments passed when requesting the service
117
+ :param logger: optional logger
118
+ :return: the callback URL, with the appropriate parameters
119
+ """
120
+
121
+ # retrieve user data
122
+ oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
123
+
124
+ # build the user data
125
+ # ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
126
+ user_data: dict[str, Any] = _get_user_data(registry=registry,
127
+ user_id=oauth_state,
128
+ logger=logger)
129
+ user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
130
+ user_data["login-id"] = user_id
131
+ timeout: int = _get_login_timeout(registry=registry)
132
+ user_data["login-expiration"] = int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout if timeout else None
133
+
134
+ # build the redirect url
135
+ result: str = (f"{registry["base-url"]}/protocol/openid-connect/auth?response_type=code"
136
+ f"&client_id={registry["client-id"]}"
137
+ f"&redirect_uri={registry["callback-url"]}"
138
+ f"&state={oauth_state}")
139
+ scope: str = _get_user_scope(registry=registry,
140
+ user_id=user_id)
141
+ if scope:
142
+ user_data["oauth-scope"] = scope
143
+ result += f"&scope={scope}"
144
+
145
+ # logout the user
146
+ _service_logout(registry=registry,
147
+ args=args,
148
+ logger=logger)
149
+ return result
150
+
151
+
152
+ def _service_logout(registry: dict[str, Any],
153
+ args: dict[str, Any],
154
+ logger: Logger | None) -> None:
155
+ """
156
+ Remove all data associating *user_id* from *registry*.
157
+
158
+ :param registry: the registry holding the authentication data
159
+ :param args: the arguments passed when requesting the service
160
+ :param logger: optional logger
161
+ """
162
+ # remove the user data
163
+ user_id: str = args.get("user-id") or args.get("login")
164
+ if user_id:
165
+ cache: Cache = registry["safe-cache"]
166
+ users: dict[str, dict[str, Any]] = cache.get("users")
167
+ if user_id in users:
168
+ users.pop(user_id)
169
+ if logger:
170
+ logger.debug(msg=f"User '{user_id}' removed from the registry")
171
+
172
+
173
+ def _service_token(registry: dict[str, Any],
174
+ args: dict[str, Any],
175
+ errors: list[str] = None,
176
+ logger: Logger = None) -> str:
177
+ """
178
+ Retrieve the authentication token for user *user_id*.
179
+
180
+ :param registry: the registry holding the authentication data
181
+ :param args: the arguments passed when requesting the service
182
+ :param errors: incidental error messages
183
+ :param logger: optional logger
184
+ :return: the token for *user_id*, or *None* if error
185
+ """
186
+ # initialize the return variable
187
+ result: str | None = None
188
+
189
+ user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
190
+ user_data: dict[str, Any] = _get_user_data(registry=registry,
191
+ user_id=user_id,
192
+ logger=logger)
193
+ token: str = user_data["access-token"]
194
+ if token:
195
+ access_expiration: int = user_data.get("access-expiration")
196
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
197
+ if now < access_expiration:
198
+ result = token
199
+ else:
200
+ # access token has expired
201
+ refresh_token: str = user_data["refresh-token"]
202
+ if refresh_token:
203
+ body_data: dict[str, str] = {
204
+ "grant_type": "refresh_token",
205
+ "refresh_token": refresh_token
206
+ }
207
+ result = _post_for_token(registry=registry,
208
+ user_data=user_data,
209
+ body_data=body_data,
210
+ errors=errors,
211
+ logger=logger)
212
+
213
+ elif logger or isinstance(errors, list):
214
+ err_msg: str = f"User '{user_id}' not authenticated"
215
+ if isinstance(errors, list):
216
+ errors.append(err_msg)
217
+ if logger:
218
+ logger.error(msg=err_msg)
219
+
220
+ return result
221
+
222
+
223
+ def _get_public_key(registry: dict[str, Any],
224
+ logger: Logger | None) -> bytes:
225
+ """
226
+ Obtain the public key used by the *IAM* to sign the authentication tokens.
227
+
228
+ The public key is saved in *registry*.
229
+
230
+ :param registry: the registry holding the authentication data
231
+ :return: the public key, in *DER* format
232
+ """
233
+ from pypomes_crypto import crypto_jwk_convert
234
+
235
+ # initialize the return variable
236
+ result: bytes | None = None
237
+
238
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
239
+ if now > registry["key-expiration"]:
240
+ # obtain a new public key
241
+ url: str = f"{registry["base-url"]}/protocol/openid-connect/certs"
242
+ if logger:
243
+ logger.debug(msg=f"GET '{url}'")
244
+ response: requests.Response = requests.get(url=url)
245
+ if response.status_code == 200:
246
+ # request succeeded
247
+ if logger:
248
+ logger.debug(msg=f"GET success, status {response.status_code}")
249
+ reply: dict[str, Any] = response.json()
250
+ result = crypto_jwk_convert(jwk=reply["keys"][0],
251
+ fmt="DER")
252
+ registry["public-key"] = result
253
+ duration: int = registry["key-lifetime"] or 0
254
+ registry["key-expiration"] = now + duration
255
+ elif logger:
256
+ msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
257
+ if hasattr(response, "content") and response.content:
258
+ msg += f", content '{response.content}'"
259
+ logger.error(msg=msg)
260
+ else:
261
+ result = registry["public-key"]
262
+
263
+ return result
264
+
265
+
266
+ def _get_login_timeout(registry: dict[str, Any]) -> int | None:
267
+ """
268
+ Retrieve from *registry* the timeout currently applicable for the login operation.
269
+
270
+ :param registry: the registry holding the authentication data
271
+ :return: the current login timeout, or *None* if none has been set.
272
+ """
273
+ timeout: int = registry.get("client-timeout")
274
+ return timeout if isinstance(timeout, int) and timeout > 0 else None
275
+
276
+
277
+ def _get_user_data(registry: dict[str, Any],
278
+ user_id: str,
279
+ logger: Logger | None) -> dict[str, Any]:
280
+ """
281
+ Retrieve the data for *user_id* from *registry*.
282
+
283
+ If an entry is not found for *user_id* in the registry, it is created.
284
+ It will remain there until the user is logged out.
285
+
286
+ :param registry: the registry holding the authentication data
287
+ :return: the data for *user_id* in the registry
288
+ """
289
+ cache: Cache = registry["safe-cache"]
290
+ users: dict[str, dict[str, Any]] = cache.get("users")
291
+ result: dict[str, Any] = users.get(user_id)
292
+ if not result:
293
+ result = {
294
+ "access-token": None,
295
+ "refresh-token": None,
296
+ "access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())
297
+ }
298
+ users[user_id] = result
299
+ if logger:
300
+ logger.debug(msg=f"Entry for user '{user_id}' added to the registry")
301
+ elif logger:
302
+ logger.debug(msg=f"Entry for user '{user_id}' obtained from the registry")
303
+
304
+ return result
305
+
306
+
307
+ def _get_user_scope(registry: dict[str, Any],
308
+ user_id: str) -> str | None:
309
+ """
310
+ Retrieve the OAuth2 scope associated with *user_id*.
311
+
312
+ :param registry: the registry holding the authentication data
313
+ :param user_id:
314
+ :return: the OAuth2 scope associated with *user_id*, or *None* if it does not exist
315
+ """
316
+ # initialize the return variable
317
+ result: str | None = None
318
+
319
+ if user_id:
320
+ cache: Cache = registry["safe-cache"]
321
+ users: dict[str, dict[str, Any]] = cache.get("users")
322
+ if user_id in users:
323
+ result = users[user_id].get("oauth2-scope")
324
+
325
+ return result
326
+
327
+
328
+ def _post_for_token(registry: dict[str, Any],
329
+ user_data: dict[str, Any],
330
+ body_data: dict[str, Any],
331
+ errors: list[str] | None,
332
+ logger: Logger | None) -> str | None:
333
+ """
334
+ Send a POST request to obtain the authentication token data, and return the access token.
335
+
336
+ For token exchange, *body_data* will have the attributes
337
+ - "grant_type": "authorization_code"
338
+ - "code": <16-character-random-code>
339
+ - "redirect_uri": <callback-url>
340
+ For token refresh, *body_data* will have the attributes
341
+ - "grant_type": "refresh_token"
342
+ - "refresh_token": <current-refresh-token>
343
+
344
+ If the operation is successful, the token data is stored in the registry.
345
+ Otherwise, *errors* will contain the appropriate error message.
346
+
347
+ :param registry: the registry holding the authentication data
348
+ :param user_data: the user's data in the registry
349
+ :param body_data: the data to send in the body of the request
350
+ :param errors: incidental errors
351
+ :param logger: optional logger
352
+ :return: the access token obtained, or *None* if error
353
+ """
354
+ # initialize the return variable
355
+ result: str | None = None
356
+
357
+ # complete the data to send in body of request
358
+ body_data["client_id"] = registry["client-id"]
359
+ client_secret: str = registry["client-secret"]
360
+ if client_secret:
361
+ body_data["client_secret"] = client_secret
362
+
363
+ # obtain the token
364
+ err_msg: str | None = None
365
+ url: str = registry["base-url"] + "/protocol/openid-connect/token"
366
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
367
+ if logger:
368
+ logger.debug(msg=f"POST '{url}', data {json.dumps(obj=body_data,
369
+ ensure_ascii=False)}")
370
+ try:
371
+ # typical return on a token request:
372
+ # {
373
+ # "token_type": "Bearer",
374
+ # "access_token": <str>,
375
+ # "expires_in": <number-of-seconds>,
376
+ # "refresh_token": <str>
377
+ # }
378
+ response: requests.Response = requests.post(url=url,
379
+ data=body_data)
380
+ if response.status_code == 200:
381
+ # request succeeded
382
+ if logger:
383
+ logger.debug(msg=f"POST success, status {response.status_code}")
384
+ reply: dict[str, Any] = response.json()
385
+ result = reply.get("access_token")
386
+ user_data["access-token"] = result
387
+ # on token refresh, keep current refresh token if a new one is not provided
388
+ user_data["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
389
+ user_data["access-expiration"] = now + reply.get("expires_in")
390
+ else:
391
+ # request resulted in error
392
+ err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
393
+ if hasattr(response, "content") and response.content:
394
+ err_msg += f", content '{response.content}'"
395
+ if response.status_code == 400 and body_data.get("grant_type") == "refresh_token":
396
+ # refresh token is no longer valid
397
+ user_data["refresh-token"] = None
398
+ except Exception as e:
399
+ # the operation raised an exception
400
+ err_msg = exc_format(exc=e,
401
+ exc_info=sys.exc_info())
402
+ err_msg = f"POST '{url}': error '{err_msg}'"
403
+
404
+ if err_msg:
405
+ if isinstance(errors, list):
406
+ errors.append(err_msg)
407
+ if logger:
408
+ logger.error(msg=err_msg)
409
+
410
+ return result
411
+
412
+
413
+ def _log_init(request: Request) -> str:
414
+ """
415
+ Build the messages for logging the request entry.
416
+
417
+ :param request: the Request object
418
+ :return: the log message
419
+ """
420
+
421
+ params: str = json.dumps(obj=request.args,
422
+ ensure_ascii=False)
423
+ return f"Request {request.method}:{request.path}, params {params}"