pypomes-iam 0.5.1__tar.gz → 0.5.2__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.5.1
3
+ Version: 0.5.2
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.5.1"
9
+ version = "0.5.2"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -55,6 +55,64 @@ _IAM_SERVERS: Final[dict[IamServer, dict[str, Any]]] = {}
55
55
  _iam_lock: Final[RLock] = RLock()
56
56
 
57
57
 
58
+ def _iam_server_from_endpoint(endpoint: str,
59
+ errors: list[str] | None,
60
+ logger: Logger | None) -> IamServer | None:
61
+ """
62
+ Retrieve the registered *IAM* server associated with the service's invocation *endpoint*.
63
+
64
+ :param endpoint: the service's invocation endpoint
65
+ :param errors: incidental error messages
66
+ :param logger: optional logger
67
+ :return: the corresponding *IAM* server, or *None* if one could not be obtained
68
+ """
69
+ # declare the return variable
70
+ result: IamServer | None
71
+
72
+ if endpoint.startswith("jusbr"):
73
+ result = IamServer.IAM_JUSRBR
74
+ elif endpoint.startswith("keycloak"):
75
+ result = IamServer.IAM_KEYCLOAK
76
+ else:
77
+ result = None
78
+ msg: str = f"Unable to find a IAM server to service endpoint '{endpoint}'"
79
+ if logger:
80
+ logger.error(msg=msg)
81
+ if isinstance(errors, list):
82
+ errors.append(msg)
83
+
84
+ return result
85
+
86
+
87
+ def _iam_server_from_issuer(issuer: str,
88
+ errors: list[str] | None,
89
+ logger: Logger | None) -> IamServer | None:
90
+ """
91
+ Retrieve the registered *IAM* server associated with the token's *issuer*.
92
+
93
+ :param issuer: the token's issuer
94
+ :param errors: incidental error messages
95
+ :param logger: optional logger
96
+ :return: the corresponding *IAM* server, or *None* if one could not be obtained
97
+ """
98
+ # initialize the return variable
99
+ result: IamServer | None = None
100
+
101
+ for iam_server, server_data in _IAM_SERVERS.items():
102
+ if server_data["base-url"] == issuer:
103
+ result = IamServer(iam_server)
104
+ break
105
+
106
+ if not result:
107
+ msg: str = f"Unable to find a IAM server associated with token issuer '{issuer}'"
108
+ if logger:
109
+ logger.error(msg=msg)
110
+ if isinstance(errors, list):
111
+ errors.append(msg)
112
+
113
+ return result
114
+
115
+
58
116
  def _get_public_key(iam_server: IamServer,
59
117
  errors: list[str] | None,
60
118
  logger: Logger | None) -> str:
@@ -178,35 +236,6 @@ def _get_user_data(iam_server: IamServer,
178
236
  return result
179
237
 
180
238
 
181
- def _get_iam_server(endpoint: str,
182
- errors: list[str] | None,
183
- logger: Logger | None) -> IamServer | None:
184
- """
185
- Retrieve the registered *IAM* server associated with the service's invocation *endpoint*.
186
-
187
- :param endpoint: the service's invocation endpoint
188
- :param errors: incidental error messages
189
- :param logger: optional logger
190
- :return: the corresponding *IAM* server, or *None* if one could not be obtained
191
- """
192
- # declare the return variable
193
- result: IamServer | None
194
-
195
- if endpoint.startswith("jusbr"):
196
- result = IamServer.IAM_JUSRBR
197
- elif endpoint.startswith("keycloak"):
198
- result = IamServer.IAM_KEYCLOAK
199
- else:
200
- result = None
201
- msg: str = f"Unable to find a IAM server to service endpoint '{endpoint}'"
202
- if logger:
203
- logger.error(msg=msg)
204
- if isinstance(errors, list):
205
- errors.append(msg)
206
-
207
- return result
208
-
209
-
210
239
  def _get_iam_registry(iam_server: IamServer,
211
240
  errors: list[str] | None,
212
241
  logger: Logger | None) -> dict[str, Any]:
@@ -4,16 +4,34 @@ import secrets
4
4
  import string
5
5
  import sys
6
6
  from datetime import datetime
7
+ from flask import Request, Response, request
7
8
  from logging import Logger
8
9
  from pypomes_core import TZ_LOCAL, exc_format
9
10
  from typing import Any
10
11
 
11
12
  from .iam_common import (
12
13
  IamServer, _iam_lock,
13
- _get_iam_users, _get_iam_registry,
14
- _get_login_timeout, _get_user_data, # _get_public_key
14
+ _get_iam_users, _get_iam_registry, # _get_public_key,
15
+ _get_login_timeout, _get_user_data, _iam_server_from_issuer
15
16
  )
16
- from .token_pomes import token_validate
17
+ from .token_pomes import token_get_claims, token_validate
18
+
19
+
20
+ def jwt_required(func: callable) -> callable:
21
+ """
22
+ Create a decorator to authenticate service endpoints with JWT tokens.
23
+
24
+ :param func: the function being decorated
25
+ """
26
+ # ruff: noqa: ANN003 - Missing type annotation for *{name}
27
+ def wrapper(*args, **kwargs) -> Response:
28
+ response: Response = __request_validate(request=request)
29
+ return response if response else func(*args, **kwargs)
30
+
31
+ # prevent a rogue error ("View function mapping is overwriting an existing endpoint function")
32
+ wrapper.__name__ = func.__name__
33
+
34
+ return wrapper
17
35
 
18
36
 
19
37
  def user_login(iam_server: IamServer,
@@ -339,6 +357,63 @@ def token_exchange(iam_server: IamServer,
339
357
  return result
340
358
 
341
359
 
360
+ def __request_validate(request: Request) -> Response:
361
+ """
362
+ Verify whether the HTTP *request* has the proper authorization, as per the JWT standard.
363
+
364
+ This implementation assumes that HTTP requests are handled with the *Flask* framework.
365
+
366
+ :param request: the *request* to be verified
367
+ :return: *None* if the *request* is valid, otherwise a *Response* reporting the error
368
+ """
369
+ # initialize the return variable
370
+ result: Response | None = None
371
+
372
+ # retrieve the authorization from the request header
373
+ auth_header: str = request.headers.get("Authorization")
374
+
375
+ # validate the authorization token
376
+ bad_token: bool = True
377
+ if auth_header and auth_header.startswith("Bearer "):
378
+ # extract and validate the JWT access token
379
+ token: str = auth_header.split(" ")[1]
380
+ claims: dict[str, Any] = token_get_claims(token=token)
381
+ if claims:
382
+ issuer: str = claims["payload"].get("iss")
383
+ recipient_attr: str | None = None
384
+ recipient_id: str = request.values.get("user-id") or request.values.get("login")
385
+ with _iam_lock:
386
+ iam_server: IamServer = _iam_server_from_issuer(issuer=issuer,
387
+ errors=None,
388
+ logger=None)
389
+ # public_key: str = _get_public_key(iam_server=iam_server,
390
+ # errors=errors,
391
+ # logger=logger)
392
+ public_key = None
393
+
394
+ # validate the token's recipient only if a user identification is provided
395
+ if recipient_id:
396
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
397
+ errors=None,
398
+ logger=None)
399
+ recipient_attr = registry["recipient-attr"]
400
+
401
+ # validate the token
402
+ if token_validate(token=token,
403
+ issuer=issuer,
404
+ recipient_id=recipient_id,
405
+ recipient_attr=recipient_attr,
406
+ public_key=public_key):
407
+ # token is valid
408
+ bad_token = False
409
+
410
+ # deny the authorization
411
+ if bad_token:
412
+ result = Response(response="Authorization failed",
413
+ status=401)
414
+ return result
415
+
416
+
342
417
  def __post_for_token(iam_server: IamServer,
343
418
  body_data: dict[str, Any],
344
419
  errors: list[str] | None,
@@ -3,7 +3,7 @@ from flask import Request, Response, request, jsonify
3
3
  from logging import Logger
4
4
  from typing import Any
5
5
 
6
- from .iam_common import IamServer, _iam_lock, _get_iam_server
6
+ from .iam_common import IamServer, _iam_lock, _iam_server_from_endpoint
7
7
  from .iam_pomes import (
8
8
  user_login, user_logout,
9
9
  user_token, token_exchange, login_callback
@@ -55,9 +55,9 @@ def service_login() -> Response:
55
55
  errors: list[str] = []
56
56
  with _iam_lock:
57
57
  # retrieve the IAM server
58
- iam_server: IamServer = _get_iam_server(endpoint=request.endpoint,
59
- errors=errors,
60
- logger=__IAM_LOGGER)
58
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
59
+ errors=errors,
60
+ logger=__IAM_LOGGER)
61
61
  if iam_server:
62
62
  # obtain the login URL
63
63
  login_url: str = user_login(iam_server=iam_server,
@@ -67,8 +67,8 @@ def service_login() -> Response:
67
67
  if login_url:
68
68
  result = jsonify({"login-url": login_url})
69
69
  if errors:
70
- result = Response("; ".join(errors))
71
- result.status_code = 400
70
+ result = Response(response="; ".join(errors),
71
+ status=400)
72
72
 
73
73
  # log the response
74
74
  if __IAM_LOGGER:
@@ -101,9 +101,9 @@ def service_logout() -> Response:
101
101
  errors: list[str] = []
102
102
  with _iam_lock:
103
103
  # retrieve the IAM server
104
- iam_server: IamServer = _get_iam_server(endpoint=request.endpoint,
105
- errors=errors,
106
- logger=__IAM_LOGGER)
104
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
105
+ errors=errors,
106
+ logger=__IAM_LOGGER)
107
107
  if iam_server:
108
108
  # logout the user
109
109
  user_logout(iam_server=iam_server,
@@ -111,8 +111,8 @@ def service_logout() -> Response:
111
111
  errors=errors,
112
112
  logger=__IAM_LOGGER)
113
113
  if errors:
114
- result = Response("; ".join(errors))
115
- result.status_code = 400
114
+ result = Response(response="; ".join(errors),
115
+ status=400)
116
116
  else:
117
117
  result = Response(status=204)
118
118
 
@@ -155,9 +155,9 @@ def service_callback() -> Response:
155
155
  token_data: tuple[str, str] | None = None
156
156
  with _iam_lock:
157
157
  # retrieve the IAM server
158
- iam_server: IamServer = _get_iam_server(endpoint=request.endpoint,
159
- errors=errors,
160
- logger=__IAM_LOGGER)
158
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
159
+ errors=errors,
160
+ logger=__IAM_LOGGER)
161
161
  if iam_server:
162
162
  # process the callback operation
163
163
  token_data = login_callback(iam_server=iam_server,
@@ -209,9 +209,9 @@ def service_token() -> Response:
209
209
  if user_id:
210
210
  with _iam_lock:
211
211
  # retrieve the IAM server
212
- iam_server: IamServer = _get_iam_server(endpoint=request.endpoint,
213
- errors=errors,
214
- logger=__IAM_LOGGER)
212
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
213
+ errors=errors,
214
+ logger=__IAM_LOGGER)
215
215
  if iam_server:
216
216
  # retrieve the token
217
217
  errors: list[str] = []
@@ -227,8 +227,8 @@ def service_token() -> Response:
227
227
 
228
228
  result: Response
229
229
  if errors:
230
- result = Response("; ".join(errors))
231
- result.status_code = 400
230
+ result = Response(response="; ".join(errors),
231
+ status=400)
232
232
  else:
233
233
  result = jsonify({"user-id": user_id,
234
234
  "access-token": token})
@@ -271,9 +271,9 @@ def service_exchange() -> Response:
271
271
  errors: list[str] = []
272
272
  with _iam_lock:
273
273
  # retrieve the IAM server (currently, only 'IAM_KEYCLOAK' is supported)
274
- iam_server: IamServer = _get_iam_server(endpoint=request.endpoint,
275
- errors=errors,
276
- logger=__IAM_LOGGER)
274
+ iam_server: IamServer = _iam_server_from_endpoint(endpoint=request.endpoint,
275
+ errors=errors,
276
+ logger=__IAM_LOGGER)
277
277
  # exchange the token
278
278
  token_data: dict[str, Any] | None = None
279
279
  if iam_server:
@@ -284,8 +284,8 @@ def service_exchange() -> Response:
284
284
  logger=__IAM_LOGGER)
285
285
  result: Response
286
286
  if errors:
287
- result = Response("; ".join(errors))
288
- result.status_code = 400
287
+ result = Response(response="; ".join(errors),
288
+ status=400)
289
289
  else:
290
290
  result = jsonify(token_data)
291
291
 
@@ -24,6 +24,7 @@ JUSBR_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_ENDPOINT
24
24
 
25
25
  JUSBR_PUBLIC_KEY_LIFETIME: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_PUBLIC_KEY_LIFETIME",
26
26
  def_value=86400) # 24 hours
27
+ JUSBR_REALM: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_REALM")
27
28
  JUSBR_RECIPIENT_ATTR: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_RECIPIENT_ATTR",
28
29
  def_value="preferred_username")
29
30
  JUSBR_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_AUTH_BASE")
@@ -31,6 +32,7 @@ JUSBR_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_AUTH_
31
32
 
32
33
  def jusbr_setup(flask_app: Flask,
33
34
  base_url: str = JUSBR_URL_AUTH_BASE,
35
+ realm: str = JUSBR_REALM,
34
36
  client_id: str = JUSBR_CLIENT_ID,
35
37
  client_secret: str = JUSBR_CLIENT_SECRET,
36
38
  client_timeout: int = JUSBR_CLIENT_TIMEOUT,
@@ -47,6 +49,7 @@ def jusbr_setup(flask_app: Flask,
47
49
 
48
50
  :param flask_app: the Flask application
49
51
  :param base_url: base URL to request JusBR services
52
+ :param realm: the JusBR realm
50
53
  :param client_id: the client's identification with JusBR
51
54
  :param client_secret: the client's password with JusBR
52
55
  :param client_timeout: timeout for login authentication (in seconds,defaults to no timeout)
@@ -64,7 +67,7 @@ def jusbr_setup(flask_app: Flask,
64
67
  cache["users"] = {}
65
68
  with _iam_lock:
66
69
  _IAM_SERVERS[IamServer.IAM_JUSRBR] = {
67
- "base-url": base_url,
70
+ "base-url": f"{base_url}/realms/{realm}",
68
71
  "client-id": client_id,
69
72
  "client-secret": client_secret,
70
73
  "client-timeout": client_timeout,
@@ -7,6 +7,45 @@ from pypomes_core import exc_format
7
7
  from typing import Any
8
8
 
9
9
 
10
+ def token_get_claims(token: str,
11
+ errors: list[str] = None,
12
+ logger: Logger = None) -> dict[str, dict[str, Any]] | None:
13
+ """
14
+ Retrieve the claims set of a JWT *token*.
15
+
16
+ Any well-constructed JWT token may be provided in *token*.
17
+ Note that neither the token's signature nor its expiration is verified.
18
+
19
+ :param token: the refrence token
20
+ :param errors: incidental error messages
21
+ :param logger: optional logger
22
+ :return: the token's claimset, or *None* if error
23
+ """
24
+ # initialize the return variable
25
+ result: dict[str, dict[str, Any]] | None = None
26
+
27
+ if logger:
28
+ logger.debug(msg="Retrieve claims for token")
29
+
30
+ try:
31
+ header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
32
+ payload: dict[str, Any] = jwt.decode(jwt=token,
33
+ options={"verify_signature": False})
34
+ result = {
35
+ "header": header,
36
+ "payload": payload
37
+ }
38
+ except Exception as e:
39
+ exc_err: str = exc_format(exc=e,
40
+ exc_info=sys.exc_info())
41
+ if logger:
42
+ logger.error(msg=f"Error retrieving the token's claims: {exc_err}")
43
+ if isinstance(errors, list):
44
+ errors.append(exc_err)
45
+
46
+ return result
47
+
48
+
10
49
  def token_validate(token: str,
11
50
  issuer: str = None,
12
51
  recipient_id: str = None,
File without changes
File without changes
File without changes