pypomes-iam 0.5.6__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.6
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.6"
9
+ version = "0.5.7"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -5,11 +5,8 @@ from .iam_actions import (
5
5
  from .iam_common import (
6
6
  IamServer
7
7
  )
8
- from .iam_pomes import (
9
- jwt_required
10
- )
11
8
  from .iam_services import (
12
- logger_register
9
+ jwt_required, logger_register
13
10
  )
14
11
  from .jusbr_pomes import (
15
12
  jusbr_setup, jusbr_get_token
@@ -30,10 +27,8 @@ __all__ = [
30
27
  "action_login", "action_logout", "action_token",
31
28
  # iam_commons
32
29
  "IamServer",
33
- # iam_pomes
34
- "jwt_required",
35
30
  # iam_services
36
- "logger_register",
31
+ "jwt_required", "logger_register",
37
32
  # jusbr_pomes
38
33
  "jusbr_setup", "jusbr_get_token",
39
34
  # keycloak_pomes
@@ -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
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
+ )
7
11
  from .iam_actions import (
8
12
  action_login, action_logout,
9
13
  action_token, action_exchange, action_callback
10
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.
@@ -1,82 +0,0 @@
1
- from flask import Request, Response, request
2
- from typing import Any
3
-
4
- from .iam_common import (
5
- IamServer, _iam_lock, _get_iam_registry,
6
- _iam_server_from_issuer # _get_public_key
7
- )
8
- from .token_pomes import token_get_claims, token_validate
9
-
10
-
11
- def jwt_required(func: callable) -> callable:
12
- """
13
- Create a decorator to authenticate service endpoints with JWT tokens.
14
-
15
- :param func: the function being decorated
16
- """
17
- # ruff: noqa: ANN003 - Missing type annotation for *{name}
18
- def wrapper(*args, **kwargs) -> Response:
19
- response: Response = __request_validate(request=request)
20
- return response if response else func(*args, **kwargs)
21
-
22
- # prevent a rogue error ("View function mapping is overwriting an existing endpoint function")
23
- wrapper.__name__ = func.__name__
24
-
25
- return wrapper
26
-
27
-
28
- def __request_validate(request: Request) -> Response:
29
- """
30
- Verify whether the HTTP *request* has the proper authorization, as per the JWT standard.
31
-
32
- This implementation assumes that HTTP requests are handled with the *Flask* framework.
33
-
34
- :param request: the *request* to be verified
35
- :return: *None* if the *request* is valid, otherwise a *Response* reporting the error
36
- """
37
- # initialize the return variable
38
- result: Response | None = None
39
-
40
- # retrieve the authorization from the request header
41
- auth_header: str = request.headers.get("Authorization")
42
-
43
- # validate the authorization token
44
- bad_token: bool = True
45
- if auth_header and auth_header.startswith("Bearer "):
46
- # extract and validate the JWT access token
47
- token: str = auth_header.split(" ")[1]
48
- claims: dict[str, Any] = token_get_claims(token=token)
49
- if claims:
50
- issuer: str = claims["payload"].get("iss")
51
- recipient_attr: str | None = None
52
- recipient_id: str = request.values.get("user-id") or request.values.get("login")
53
- with _iam_lock:
54
- iam_server: IamServer = _iam_server_from_issuer(issuer=issuer,
55
- errors=None,
56
- logger=None)
57
- # public_key: str = _get_public_key(iam_server=iam_server,
58
- # errors=errors,
59
- # logger=logger)
60
- public_key = None
61
-
62
- # validate the token's recipient only if a user identification is provided
63
- if recipient_id:
64
- registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
65
- errors=None,
66
- logger=None)
67
- recipient_attr = registry["recipient-attr"]
68
-
69
- # validate the token
70
- if token_validate(token=token,
71
- issuer=issuer,
72
- recipient_id=recipient_id,
73
- recipient_attr=recipient_attr,
74
- public_key=public_key):
75
- # token is valid
76
- bad_token = False
77
-
78
- # deny the authorization
79
- if bad_token:
80
- result = Response(response="Authorization failed",
81
- status=401)
82
- return result
File without changes
File without changes
File without changes
File without changes