pypomes-iam 0.5.5__tar.gz → 0.5.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_iam
3
- Version: 0.5.5
3
+ Version: 0.5.7
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.5"
9
+ version = "0.5.7"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
File without changes
@@ -1,10 +1,12 @@
1
- from .iam_pomes import (
2
- IamServer,
3
- login_callback, token_exchange,
4
- user_login, user_logout, user_token
1
+ from .iam_actions import (
2
+ action_callback, action_exchange,
3
+ action_login, action_logout, action_token
4
+ )
5
+ from .iam_common import (
6
+ IamServer
5
7
  )
6
8
  from .iam_services import (
7
- logger_register
9
+ jwt_required, logger_register
8
10
  )
9
11
  from .jusbr_pomes import (
10
12
  jusbr_setup, jusbr_get_token
@@ -20,12 +22,13 @@ from .token_pomes import (
20
22
  )
21
23
 
22
24
  __all__ = [
23
- # iam_pomes
25
+ # iam_actions
26
+ "action_callback", "action_exchange",
27
+ "action_login", "action_logout", "action_token",
28
+ # iam_commons
24
29
  "IamServer",
25
- "login_callback", "token_exchange",
26
- "user_login", "user_logout", "user_token",
27
30
  # iam_services
28
- "logger_register",
31
+ "jwt_required", "logger_register",
29
32
  # jusbr_pomes
30
33
  "jusbr_setup", "jusbr_get_token",
31
34
  # keycloak_pomes
@@ -4,7 +4,6 @@ import secrets
4
4
  import string
5
5
  import sys
6
6
  from datetime import datetime
7
- from flask import Request, Response, request
8
7
  from logging import Logger
9
8
  from pypomes_core import TZ_LOCAL, exc_format
10
9
  from typing import Any
@@ -12,32 +11,15 @@ from typing import Any
12
11
  from .iam_common import (
13
12
  IamServer, _iam_lock,
14
13
  _get_iam_users, _get_iam_registry, # _get_public_key,
15
- _get_login_timeout, _get_user_data, _iam_server_from_issuer
14
+ _get_login_timeout, _get_user_data
16
15
  )
17
- from .token_pomes import token_get_claims, token_validate
16
+ from .token_pomes import token_validate
18
17
 
19
18
 
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
35
-
36
-
37
- def user_login(iam_server: IamServer,
38
- args: dict[str, Any],
39
- errors: list[str] = None,
40
- logger: Logger = None) -> str:
19
+ def action_login(iam_server: IamServer,
20
+ args: dict[str, Any],
21
+ errors: list[str] = None,
22
+ logger: Logger = None) -> str:
41
23
  """
42
24
  Build the URL for redirecting the request to *iam_server*'s authentication page.
43
25
 
@@ -95,10 +77,10 @@ def user_login(iam_server: IamServer,
95
77
  return result
96
78
 
97
79
 
98
- def user_logout(iam_server: IamServer,
99
- args: dict[str, Any],
100
- errors: list[str] = None,
101
- logger: Logger = None) -> None:
80
+ def action_logout(iam_server: IamServer,
81
+ args: dict[str, Any],
82
+ errors: list[str] = None,
83
+ logger: Logger = None) -> None:
102
84
  """
103
85
  Logout the user, by removing all data associating it from *iam_server*'s registry.
104
86
 
@@ -126,10 +108,10 @@ def user_logout(iam_server: IamServer,
126
108
  logger.debug(msg=f"User '{user_id}' removed from {iam_server}'s registry")
127
109
 
128
110
 
129
- def user_token(iam_server: IamServer,
130
- args: dict[str, Any],
131
- errors: list[str] = None,
132
- logger: Logger = None) -> str:
111
+ def action_token(iam_server: IamServer,
112
+ args: dict[str, Any],
113
+ errors: list[str] = None,
114
+ logger: Logger = None) -> str:
133
115
  """
134
116
  Retrieve the authentication token for the user, from *iam_server*.
135
117
 
@@ -212,10 +194,10 @@ def user_token(iam_server: IamServer,
212
194
  return result
213
195
 
214
196
 
215
- def login_callback(iam_server: IamServer,
216
- args: dict[str, Any],
217
- errors: list[str] = None,
218
- logger: Logger = None) -> tuple[str, str] | None:
197
+ def action_callback(iam_server: IamServer,
198
+ args: dict[str, Any],
199
+ errors: list[str] = None,
200
+ logger: Logger = None) -> tuple[str, str] | None:
219
201
  """
220
202
  Entry point for the callback from *iam_server* via the front-end application, on authentication operations.
221
203
 
@@ -282,10 +264,10 @@ def login_callback(iam_server: IamServer,
282
264
  return result
283
265
 
284
266
 
285
- def token_exchange(iam_server: IamServer,
286
- args: dict[str, Any],
287
- errors: list[str] = None,
288
- logger: Logger = None) -> dict[str, Any]:
267
+ def action_exchange(iam_server: IamServer,
268
+ args: dict[str, Any],
269
+ errors: list[str] = None,
270
+ logger: Logger = None) -> dict[str, Any]:
289
271
  """
290
272
  Request *iam_server* to issue a token in exchange for the token obtained from another *IAM* server.
291
273
 
@@ -357,63 +339,6 @@ def token_exchange(iam_server: IamServer,
357
339
  return result
358
340
 
359
341
 
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
-
417
342
  def __post_for_token(iam_server: IamServer,
418
343
  body_data: dict[str, Any],
419
344
  errors: list[str] | None,
@@ -50,7 +50,7 @@ class IamServer(StrEnum):
50
50
  # }
51
51
  _IAM_SERVERS: Final[dict[IamServer, dict[str, Any]]] = {}
52
52
 
53
- # the lock protecting the data in '_IAM_SERVER'
53
+ # the lock protecting the data in '_IAM_SERVERS'
54
54
  # (because it is 'Final' and set at declaration time, it can be accessed through simple imports)
55
55
  _iam_lock: Final[RLock] = RLock()
56
56
 
@@ -119,12 +119,39 @@ def _get_public_key(iam_server: IamServer,
119
119
  """
120
120
  Obtain the public key used by *iam_server* to sign the authentication tokens.
121
121
 
122
- The public key is saved in *iam_server*'s registry.
122
+ This is accomplished by requesting the token issuer for its *JWKS* (JSON Web Key Set),
123
+ containing the public keys used for various purposes, as indicated in the attribute *use*:
124
+ - *enc*: the key is intended for encryption
125
+ - *sig*: the key is intended for digital signature
126
+ - *wrap*: the key is intended for key wrapping
127
+
128
+ A typical JWKS set has the following format (for simplicity, 'n' and 'x5c' are truncated):
129
+ {
130
+ "keys": [
131
+ {
132
+ "kid": "X2QEcSQ4Tg2M2EK6s2nhRHZH_GwD_zxZtiWVwP4S0tg",
133
+ "kty": "RSA",
134
+ "alg": "RSA256",
135
+ "use": "sig",
136
+ "n": "tQmDmyM3tMFt5FMVMbqbQYpaDPf6A5l4e_kTVDBiHrK_bRlGfkk8hYm5SNzNzCZ...",
137
+ "e": "AQAB",
138
+ "x5c": [
139
+ "MIIClzCCAX8CBgGZY0bqrTANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDARpanVk..."
140
+ ],
141
+ "x5t": "MHfVp4kBjEZuYOtiaaGsfLCL15Q",
142
+ "x5t#S256": "QADezSLgD8emuonBz8hn8ghTnxo7AHX4NVNkr4luEhk"
143
+ },
144
+ ...
145
+ ]
146
+ }
147
+
148
+ Once the signature key is obtained, it is converted from its original *JWK* (JSON Web Key) format
149
+ to *PEM* (Privacy-Enhanced Mail) format. The public key is saved in *iam_server*'s registry.
123
150
 
124
151
  :param iam_server: the reference registered *IAM* server
125
152
  :param errors: incidental error messages
126
153
  :param logger: optional logger
127
- :return: the public key in *PEM* format, or *None* if the server is unknown
154
+ :return: the public key in *PEM* format, or *None* if error
128
155
  """
129
156
  # initialize the return variable
130
157
  result: str | None = None
@@ -135,9 +162,10 @@ def _get_public_key(iam_server: IamServer,
135
162
  if registry:
136
163
  now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
137
164
  if now > registry["pk-expiration"]:
138
- # obtain a new public key
165
+ # obtain the JWKS (JSON Web Key Set) from the token issuer
139
166
  url: str = f"{registry["base-url"]}/protocol/openid-connect/certs"
140
167
  if logger:
168
+ logger.debug(msg=f"Obtaining signature public key used by IAM server '{iam_server}'")
141
169
  logger.debug(msg=f"GET {url}")
142
170
  try:
143
171
  response: requests.Response = requests.get(url=url)
@@ -145,12 +173,29 @@ def _get_public_key(iam_server: IamServer,
145
173
  # request succeeded
146
174
  if logger:
147
175
  logger.debug(msg=f"GET success, status {response.status_code}")
176
+ # select the appropriate JWK
148
177
  reply: dict[str, Any] = response.json()
149
- result = crypto_jwk_convert(jwk=reply["keys"][0],
150
- fmt="PEM")
151
- registry["public-key"] = result
152
- lifetime: int = registry["pk-lifetime"] or 0
153
- registry["pk-expiration"] = now + lifetime
178
+ jwk: dict[str, str] | None = None
179
+ # replay["keys"]: list[dict[str, str]]
180
+ for key in reply["keys"]:
181
+ if key.get("use") == "sig":
182
+ jwk = key
183
+ break
184
+ if jwk:
185
+ # convert from 'JWK' to 'PEM' and save it for further use
186
+ result = crypto_jwk_convert(jwk=jwk,
187
+ fmt="PEM")
188
+ registry["public-key"] = result
189
+ lifetime: int = registry["pk-lifetime"] or 0
190
+ registry["pk-expiration"] = now + lifetime if lifetime else sys.maxsize
191
+ if logger:
192
+ logger.debug(f"Public key obtained and saved")
193
+ else:
194
+ msg = "Signature public key missing from the token issuer's JWKS"
195
+ if logger:
196
+ logger.error(msg=msg)
197
+ if isinstance(errors, list):
198
+ errors.append(msg)
154
199
  elif logger:
155
200
  msg: str = f"GET failure, status {response.status_code}, reason {response.reason}"
156
201
  if hasattr(response, "content") and response.content:
@@ -3,17 +3,102 @@ 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, _iam_server_from_endpoint
7
- from .iam_pomes import (
8
- user_login, user_logout,
9
- user_token, token_exchange, login_callback
6
+ from .iam_common import (
7
+ IamServer, _iam_lock,
8
+ _get_iam_registry, _get_public_key,
9
+ _iam_server_from_endpoint, _iam_server_from_issuer
10
10
  )
11
+ from .iam_actions import (
12
+ action_login, action_logout,
13
+ action_token, action_exchange, action_callback
14
+ )
15
+ from .token_pomes import token_get_claims, token_validate
11
16
 
12
17
  # the logger for IAM service operations
13
18
  # (used exclusively at the HTTP endpoints - all other functions receive the logger as parameter)
14
19
  __IAM_LOGGER: Logger | None = None
15
20
 
16
21
 
22
+ def jwt_required(func: callable) -> callable:
23
+ """
24
+ Create a decorator to authenticate service endpoints with JWT tokens.
25
+
26
+ :param func: the function being decorated
27
+ """
28
+ # ruff: noqa: ANN003 - Missing type annotation for *{name}
29
+ def wrapper(*args, **kwargs) -> Response:
30
+ response: Response = __request_validate(request=request)
31
+ return response if response else func(*args, **kwargs)
32
+
33
+ # prevent a rogue error ("View function mapping is overwriting an existing endpoint function")
34
+ wrapper.__name__ = func.__name__
35
+
36
+ return wrapper
37
+
38
+
39
+ def __request_validate(request: Request) -> Response:
40
+ """
41
+ Verify whether the HTTP *request* has the proper authorization, as per the JWT standard.
42
+
43
+ This implementation assumes that HTTP requests are handled with the *Flask* framework.
44
+ Because this code has a high usage frequency, only authentication failures are logged.
45
+
46
+ :param request: the *request* to be verified
47
+ :return: *None* if the *request* is valid, otherwise a *Response* reporting the error
48
+ """
49
+ # initialize the return variable
50
+ result: Response | None = None
51
+
52
+ # retrieve the authorization from the request header
53
+ auth_header: str = request.headers.get("Authorization")
54
+
55
+ # validate the authorization token
56
+ bad_token: bool = True
57
+ if auth_header and auth_header.startswith("Bearer "):
58
+ # extract and validate the JWT access token
59
+ token: str = auth_header.split(" ")[1]
60
+ claims: dict[str, Any] = token_get_claims(token=token)
61
+ if claims:
62
+ issuer: str = claims["payload"].get("iss")
63
+ recipient_attr: str | None = None
64
+ recipient_id: str = request.values.get("user-id") or request.values.get("login")
65
+ with _iam_lock:
66
+ iam_server: IamServer = _iam_server_from_issuer(issuer=issuer,
67
+ errors=None,
68
+ logger=__IAM_LOGGER)
69
+ if iam_server:
70
+ # validate the token's recipient only if a user identification is provided
71
+ if recipient_id:
72
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
73
+ errors=None,
74
+ logger=__IAM_LOGGER)
75
+ if registry:
76
+ recipient_attr = registry["recipient-attr"]
77
+ public_key: str = _get_public_key(iam_server=iam_server,
78
+ errors=None,
79
+ logger=__IAM_LOGGER)
80
+ # validate the token (log errors, only)
81
+ errors: list[str] = []
82
+ if public_key and token_validate(token=token,
83
+ issuer=issuer,
84
+ recipient_id=recipient_id,
85
+ recipient_attr=recipient_attr,
86
+ public_key=public_key,
87
+ errors=errors):
88
+ # token is valid
89
+ bad_token = False
90
+ elif __IAM_LOGGER:
91
+ __IAM_LOGGER.error("; ".join(errors))
92
+ if bad_token and __IAM_LOGGER:
93
+ __IAM_LOGGER.error(f"authorization refused for token {token}")
94
+
95
+ # deny the authorization
96
+ if bad_token:
97
+ result = Response(response="Authorization failed",
98
+ status=401)
99
+ return result
100
+
101
+
17
102
  def logger_register(logger: Logger) -> None:
18
103
  """
19
104
  Register the logger for HTTP services.
@@ -60,10 +145,10 @@ def service_login() -> Response:
60
145
  logger=__IAM_LOGGER)
61
146
  if iam_server:
62
147
  # obtain the login URL
63
- login_url: str = user_login(iam_server=iam_server,
64
- args=request.args,
65
- errors=errors,
66
- logger=__IAM_LOGGER)
148
+ login_url: str = action_login(iam_server=iam_server,
149
+ args=request.args,
150
+ errors=errors,
151
+ logger=__IAM_LOGGER)
67
152
  if login_url:
68
153
  result = jsonify({"login-url": login_url})
69
154
  if errors:
@@ -106,10 +191,10 @@ def service_logout() -> Response:
106
191
  logger=__IAM_LOGGER)
107
192
  if iam_server:
108
193
  # logout the user
109
- user_logout(iam_server=iam_server,
110
- args=request.args,
111
- errors=errors,
112
- logger=__IAM_LOGGER)
194
+ action_logout(iam_server=iam_server,
195
+ args=request.args,
196
+ errors=errors,
197
+ logger=__IAM_LOGGER)
113
198
  if errors:
114
199
  result = Response(response="; ".join(errors),
115
200
  status=400)
@@ -160,10 +245,10 @@ def service_callback() -> Response:
160
245
  logger=__IAM_LOGGER)
161
246
  if iam_server:
162
247
  # process the callback operation
163
- token_data = login_callback(iam_server=iam_server,
164
- args=request.args,
165
- errors=errors,
166
- logger=__IAM_LOGGER)
248
+ token_data = action_callback(iam_server=iam_server,
249
+ args=request.args,
250
+ errors=errors,
251
+ logger=__IAM_LOGGER)
167
252
  result: Response
168
253
  if errors:
169
254
  result = jsonify({"errors": "; ".join(errors)})
@@ -215,10 +300,10 @@ def service_token() -> Response:
215
300
  if iam_server:
216
301
  # retrieve the token
217
302
  errors: list[str] = []
218
- token: str = user_token(iam_server=iam_server,
219
- args=args,
220
- errors=errors,
221
- logger=__IAM_LOGGER)
303
+ token: str = action_token(iam_server=iam_server,
304
+ args=args,
305
+ errors=errors,
306
+ logger=__IAM_LOGGER)
222
307
  else:
223
308
  msg: str = "User identification not provided"
224
309
  errors.append(msg)
@@ -278,10 +363,10 @@ def service_exchange() -> Response:
278
363
  token_data: dict[str, Any] | None = None
279
364
  if iam_server:
280
365
  errors: list[str] = []
281
- token_data = token_exchange(iam_server=iam_server,
282
- args=request.args,
283
- errors=errors,
284
- logger=__IAM_LOGGER)
366
+ token_data = action_exchange(iam_server=iam_server,
367
+ args=request.args,
368
+ errors=errors,
369
+ logger=__IAM_LOGGER)
285
370
  result: Response
286
371
  if errors:
287
372
  result = Response(response="; ".join(errors),
@@ -7,7 +7,7 @@ from pypomes_core import (
7
7
  from typing import Any, Final
8
8
 
9
9
  from .iam_common import _IAM_SERVERS, IamServer, _iam_lock
10
- from .iam_pomes import user_token
10
+ from .iam_actions import action_token
11
11
 
12
12
  JUSBR_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_ID")
13
13
  JUSBR_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_SECRET")
@@ -118,8 +118,8 @@ def jusbr_get_token(user_id: str,
118
118
  # retrieve the token
119
119
  args: dict[str, Any] = {"user-id": user_id}
120
120
  with _iam_lock:
121
- result = user_token(iam_server=IamServer.IAM_JUSRBR,
122
- args=args,
123
- errors=errors,
124
- logger=logger)
121
+ result = action_token(iam_server=IamServer.IAM_JUSRBR,
122
+ args=args,
123
+ errors=errors,
124
+ logger=logger)
125
125
  return result
@@ -7,7 +7,7 @@ from pypomes_core import (
7
7
  from typing import Any, Final
8
8
 
9
9
  from .iam_common import _IAM_SERVERS, IamServer, _iam_lock
10
- from .iam_pomes import user_token
10
+ from .iam_actions import action_token
11
11
 
12
12
  KEYCLOAK_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_ID")
13
13
  KEYCLOAK_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_SECRET")
@@ -129,8 +129,8 @@ def keycloak_get_token(user_id: str,
129
129
  # retrieve the token
130
130
  args: dict[str, Any] = {"user-id": user_id}
131
131
  with _iam_lock:
132
- result = user_token(iam_server=IamServer.IAM_KEYCLOAK,
133
- args=args,
134
- errors=errors,
135
- logger=logger)
132
+ result = action_token(iam_server=IamServer.IAM_KEYCLOAK,
133
+ args=args,
134
+ errors=errors,
135
+ logger=logger)
136
136
  return result
@@ -5,7 +5,7 @@ from base64 import b64encode
5
5
  from datetime import datetime
6
6
  from logging import Logger
7
7
  from pypomes_core import TZ_LOCAL, exc_format
8
- from threading import RLock
8
+ from threading import Lock
9
9
  from typing import Any, Final
10
10
 
11
11
  # structure:
@@ -25,7 +25,7 @@ _provider_registry: Final[dict[str, dict[str, Any]]] = {}
25
25
 
26
26
  # the lock protecting the data in '_provider_registry'
27
27
  # (because it is 'Final' and set at declaration time, it can be accessed through simple imports)
28
- _provider_lock: Final[RLock] = RLock()
28
+ _provider_lock: Final[Lock] = Lock()
29
29
 
30
30
 
31
31
  def provider_register(provider_id: str,
File without changes
File without changes
File without changes