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.

@@ -7,8 +7,49 @@ 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,
51
+ recipient_id: str = None,
52
+ recipient_attr: str = None,
12
53
  public_key: str | bytes | PyJWK | RSAPublicKey = None,
13
54
  errors: list[str] = None,
14
55
  logger: Logger = None) -> dict[str, dict[str, Any]] | None:
@@ -24,15 +65,21 @@ def token_validate(token: str,
24
65
  If an asymmetric algorithm was used to sign the token and *public_key* is provided, then
25
66
  the token is validated, by using the data in its *signature* section.
26
67
 
68
+ The parameters *recipient_id* and *recipient_attr* refer the token's expected subject, respectively,
69
+ the subject's identification and the attribute in the token's payload data identifying its subject.
70
+ If both are provided, *recipient_id* is validated.
71
+
27
72
  On failure, *errors* will contain the reason(s) for rejecting *token*.
28
73
  On success, return the token's claims (*header* and *payload*).
29
74
 
30
75
  :param token: the token to be validated
31
76
  :param public_key: optional public key used to sign the token, in *PEM* format
32
77
  :param issuer: optional value to compare with the token's *iss* (issuer) attribute in its *payload*
78
+ :param recipient_id: identification of the expected token subject
79
+ :param recipient_attr: attribute in the token's payload holding the expected subject's identification
33
80
  :param errors: incidental error messages
34
81
  :param logger: optional logger
35
- :return: The token's claims (*header* and *payload*) if it is valid, *None* otherwise
82
+ :return: The token's claims (*header* and *payload*), or *None* if error
36
83
  """
37
84
  # initialize the return variable
38
85
  result: dict[str, dict[str, Any]] | None = None
@@ -58,8 +105,11 @@ def token_validate(token: str,
58
105
  # validate the token
59
106
  if not errors:
60
107
  token_alg: str = token_header.get("alg")
108
+ require: list[str] = ["exp", "iat"]
109
+ if issuer:
110
+ require.append("iss")
61
111
  options: dict[str, Any] = {
62
- "require": ["exp", "iat"],
112
+ "require": require,
63
113
  "verify_aud": False,
64
114
  "verify_exp": True,
65
115
  "verify_iat": True,
@@ -67,8 +117,6 @@ def token_validate(token: str,
67
117
  "verify_nbf": False,
68
118
  "verify_signature": token_alg in ["RS256", "RS512"] and public_key is not None
69
119
  }
70
- if issuer:
71
- options["require"].append("iss")
72
120
  try:
73
121
  # raises:
74
122
  # InvalidTokenError: token is invalid
@@ -84,10 +132,17 @@ def token_validate(token: str,
84
132
  algorithms=[token_alg],
85
133
  options=options,
86
134
  issuer=issuer)
87
- result = {
88
- "header": token_header,
89
- "payload": payload
90
- }
135
+ if recipient_id and recipient_attr and \
136
+ payload.get(recipient_attr) and recipient_id != payload.get(recipient_attr):
137
+ msg: str = f"Token was issued to '{payload.get(recipient_attr)}', not to '{recipient_id}'"
138
+ if logger:
139
+ logger.error(msg=msg)
140
+ errors.append(msg)
141
+ else:
142
+ result = {
143
+ "header": token_header,
144
+ "payload": payload
145
+ }
91
146
  except Exception as e:
92
147
  exc_err: str = exc_format(exc=e,
93
148
  exc_info=sys.exc_info())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_iam
3
- Version: 0.2.3
3
+ Version: 0.7.0
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
@@ -10,7 +10,6 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.12
13
- Requires-Dist: cachetools>=6.2.1
14
13
  Requires-Dist: flask>=3.1.2
15
14
  Requires-Dist: pyjwt>=2.10.1
16
15
  Requires-Dist: pypomes-core>=2.8.1
@@ -0,0 +1,11 @@
1
+ pypomes_iam/__init__.py,sha256=_6tSFfjuU-5p6TAMqNLHSL6IQmaJMSYuEW-TG3ybhTI,1044
2
+ pypomes_iam/iam_actions.py,sha256=5nomjeylTUSEtLCAvRnM1ayblsVx2hGDYzQn2twk8kk,42727
3
+ pypomes_iam/iam_common.py,sha256=ki_-m6fqJqUbGjgTD41r9zaE-FOXgA_c_tLisIYYTfU,15457
4
+ pypomes_iam/iam_pomes.py,sha256=_kLnrZG25XhJsIv3wqDl_2sIJ2ho_2TIMKrPCyPmA7Q,7362
5
+ pypomes_iam/iam_services.py,sha256=uUD333SaTbo8MGRyIp5GGil7HAupK73ym4_bKtGkPFg,15878
6
+ pypomes_iam/provider_pomes.py,sha256=3mMj5LQs53YEINUEOfFBAxOwOP3aOR_szlE4daEBLK0,10523
7
+ pypomes_iam/token_pomes.py,sha256=K4nSAotKUoHIE2s3ltc_nVimlNeKS9tnD-IlslkAvkk,6626
8
+ pypomes_iam-0.7.0.dist-info/METADATA,sha256=H2XjOEqG8t1umbwLIj8CM4AU0G9ufNN0mbEPIHfH4ko,661
9
+ pypomes_iam-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ pypomes_iam-0.7.0.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
11
+ pypomes_iam-0.7.0.dist-info/RECORD,,
@@ -1,397 +0,0 @@
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 pypomes_crypto import crypto_jwk_convert
12
- from typing import Any
13
-
14
- # registry structure:
15
- # {
16
- # "client-id": <str>,
17
- # "client-secret": <str>,
18
- # "client-timeout": <int>,
19
- # "public_key": <str>,
20
- # "key-lifetime": <int>,
21
- # "key-expiration": <int>,
22
- # "base-url": <str>,
23
- # "callback-url": <str>,
24
- # "safe-cache": <FIFOCache>
25
- # }
26
- # data in "safe-cache":
27
- # {
28
- # "users": {
29
- # "<user-id>": {
30
- # "access-token": <str>
31
- # "refresh-token": <str>
32
- # "access-expiration": <timestamp>,
33
- # "login-expiration": <timestamp>, <-- transient
34
- # "login-id": <str>, <-- transient
35
- # }
36
- # }
37
- # }
38
-
39
-
40
- def _service_login(registry: dict[str, Any],
41
- args: dict[str, Any],
42
- logger: Logger | None) -> str:
43
- """
44
- Build the callback URL for redirecting the request to the IAM's authentication page.
45
-
46
- :param registry: the registry holding the authentication data
47
- :param args: the arguments passed when requesting the service
48
- :param logger: optional logger
49
- :return: the callback URL, with the appropriate parameters
50
- """
51
-
52
- # retrieve user data
53
- oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
54
-
55
- # build the user data
56
- # ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
57
- user_data: dict[str, Any] = _get_user_data(registry=registry,
58
- user_id=oauth_state,
59
- logger=logger)
60
- user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
61
- user_data["login-id"] = user_id
62
- timeout: int = _get_login_timeout(registry=registry)
63
- user_data["login-expiration"] = int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout if timeout else None
64
-
65
- # build the redirect url
66
- result: str = (f"{registry["base-url"]}/protocol/openid-connect/auth"
67
- f"?response_type=code&scope=openid"
68
- f"&client_id={registry["client-id"]}"
69
- f"&redirect_uri={registry["callback-url"]}"
70
- f"&state={oauth_state}")
71
-
72
- # logout the user
73
- _service_logout(registry=registry,
74
- args=args,
75
- logger=logger)
76
- return result
77
-
78
-
79
- def _service_logout(registry: dict[str, Any],
80
- args: dict[str, Any],
81
- logger: Logger | None) -> None:
82
- """
83
- Remove all data associating *user_id* from *registry*.
84
-
85
- :param registry: the registry holding the authentication data
86
- :param args: the arguments passed when requesting the service
87
- :param logger: optional logger
88
- """
89
- # remove the user data
90
- user_id: str = args.get("user-id") or args.get("login")
91
- if user_id:
92
- cache: Cache = registry["safe-cache"]
93
- users: dict[str, dict[str, Any]] = cache.get("users")
94
- if user_id in users:
95
- users.pop(user_id)
96
- if logger:
97
- logger.debug(msg=f"User '{user_id}' removed from the registry")
98
-
99
-
100
- def _service_callback(registry: dict[str, Any],
101
- args: dict[str, Any],
102
- errors: list[str],
103
- logger: Logger | None) -> tuple[str, str]:
104
- """
105
- Entry point for the callback from JusBR on authentication operation.
106
-
107
- :param registry: the registry holding the authentication data
108
- :param args: the arguments passed when requesting the service
109
- :param errors: incidental errors
110
- :param logger: optional logger
111
- """
112
- from .token_pomes import token_validate
113
-
114
- # initialize the return variable
115
- result: tuple[str, str] | None = None
116
-
117
- # retrieve the users authentication data
118
- cache: Cache = registry["safe-cache"]
119
- users: dict[str, dict[str, Any]] = cache.get("users")
120
-
121
- # validate the OAuth2 state
122
- oauth_state: str = args.get("state")
123
- user_data: dict[str, Any] | None = None
124
- if oauth_state:
125
- for user, data in users.items():
126
- if user == oauth_state:
127
- user_data = data
128
- break
129
-
130
- # exchange 'code' for the token
131
- if user_data:
132
- expiration: int = user_data["login-expiration"] or sys.maxsize
133
- if int(datetime.now(tz=TZ_LOCAL).timestamp()) > expiration:
134
- errors.append("Operation timeout")
135
- else:
136
- users.pop(oauth_state)
137
- code: str = args.get("code")
138
- body_data: dict[str, Any] = {
139
- "grant_type": "authorization_code",
140
- "code": code,
141
- "redirect_uri": registry.get("callback-url"),
142
- }
143
- token = _post_for_token(registry=registry,
144
- user_data=user_data,
145
- body_data=body_data,
146
- errors=errors,
147
- logger=logger)
148
- # retrieve the token's claims
149
- if not errors:
150
- public_key: bytes = _get_public_key(registry=registry,
151
- logger=logger)
152
- token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
153
- issuer=registry["base-url"],
154
- public_key=public_key,
155
- errors=errors,
156
- logger=logger)
157
- if not errors:
158
- token_user: str = token_claims["payload"].get("preferred_username")
159
- if token_user == oauth_state:
160
- users[token_user] = user_data
161
- result = (token_user, token)
162
- else:
163
- errors.append(f"Token was issued to user '{token_user}'")
164
- else:
165
- errors.append("Unknown state received")
166
-
167
- return result
168
-
169
-
170
- def _service_token(registry: dict[str, Any],
171
- args: dict[str, Any],
172
- errors: list[str] = None,
173
- logger: Logger = None) -> str:
174
- """
175
- Retrieve the authentication token for user *user_id*.
176
-
177
- :param registry: the registry holding the authentication data
178
- :param args: the arguments passed when requesting the service
179
- :param errors: incidental error messages
180
- :param logger: optional logger
181
- :return: the token for *user_id*, or *None* if error
182
- """
183
- # initialize the return variable
184
- result: str | None = None
185
-
186
- user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
187
- user_data: dict[str, Any] = _get_user_data(registry=registry,
188
- user_id=user_id,
189
- logger=logger)
190
- token: str = user_data["access-token"]
191
- if token:
192
- access_expiration: int = user_data.get("access-expiration")
193
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
194
- if now < access_expiration:
195
- result = token
196
- else:
197
- # access token has expired
198
- refresh_token: str = user_data["refresh-token"]
199
- if refresh_token:
200
- body_data: dict[str, str] = {
201
- "grant_type": "refresh_token",
202
- "refresh_token": refresh_token
203
- }
204
- result = _post_for_token(registry=registry,
205
- user_data=user_data,
206
- body_data=body_data,
207
- errors=errors,
208
- logger=logger)
209
-
210
- elif logger or isinstance(errors, list):
211
- err_msg: str = f"User '{user_id}' not authenticated"
212
- if isinstance(errors, list):
213
- errors.append(err_msg)
214
- if logger:
215
- logger.error(msg=err_msg)
216
-
217
- return result
218
-
219
-
220
- def _get_public_key(registry: dict[str, Any],
221
- logger: Logger | None) -> bytes:
222
- """
223
- Obtain the public key used by the *IAM* to sign the authentication tokens.
224
-
225
- The public key is saved in *registry*.
226
-
227
- :param registry: the registry holding the authentication data
228
- :return: the public key, in *DER* format
229
- """
230
- # initialize the return variable
231
- result: bytes | None = None
232
-
233
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
234
- if now > registry["key-expiration"]:
235
- # obtain a new public key
236
- url: str = f"{registry["base-url"]}/protocol/openid-connect/certs"
237
- if logger:
238
- logger.debug(msg=f"GET '{url}'")
239
- response: requests.Response = requests.get(url=url)
240
- if response.status_code == 200:
241
- # request succeeded
242
- if logger:
243
- logger.debug(msg=f"GET success, status {response.status_code}")
244
- reply: dict[str, Any] = response.json()
245
- result = crypto_jwk_convert(jwk=reply["keys"][0],
246
- fmt="DER")
247
- registry["public-key"] = result
248
- duration: int = registry["key-lifetime"] or 0
249
- registry["key-expiration"] = now + duration
250
- elif logger:
251
- msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
252
- if hasattr(response, "content") and response.content:
253
- msg += f", content '{response.content}'"
254
- logger.error(msg=msg)
255
- else:
256
- result = registry["public-key"]
257
-
258
- return result
259
-
260
-
261
- def _get_login_timeout(registry: dict[str, Any]) -> int | None:
262
- """
263
- Retrieve from *registry* the timeout currently applicable for the login operation.
264
-
265
- :param registry: the registry holding the authentication data
266
- :return: the current login timeout, or *None* if none has been set.
267
- """
268
- timeout: int = registry.get("client-timeout")
269
- return timeout if isinstance(timeout, int) and timeout > 0 else None
270
-
271
-
272
- def _get_user_data(registry: dict[str, Any],
273
- user_id: str,
274
- logger: Logger | None) -> dict[str, Any]:
275
- """
276
- Retrieve the data for *user_id* from *registry*.
277
-
278
- If an entry is not found for *user_id* in the registry, it is created.
279
- It will remain there until the user is logged out.
280
-
281
- :param registry: the registry holding the authentication data
282
- :return: the data for *user_id* in the registry
283
- """
284
- cache: Cache = registry["safe-cache"]
285
- users: dict[str, dict[str, Any]] = cache.get("users")
286
- result: dict[str, Any] = users.get(user_id)
287
- if not result:
288
- result = {
289
- "access-token": None,
290
- "refresh-token": None,
291
- "access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())
292
- }
293
- users[user_id] = result
294
- if logger:
295
- logger.debug(msg=f"Entry for user '{user_id}' added to the registry")
296
- elif logger:
297
- logger.debug(msg=f"Entry for user '{user_id}' obtained from the registry")
298
-
299
- return result
300
-
301
-
302
- def _post_for_token(registry: dict[str, Any],
303
- user_data: dict[str, Any],
304
- body_data: dict[str, Any],
305
- errors: list[str] | None,
306
- logger: Logger | None) -> str | None:
307
- """
308
- Send a POST request to obtain the authentication token data, and return the access token.
309
-
310
- For token exchange, *body_data* will have the attributes
311
- - "grant_type": "authorization_code"
312
- - "code": <16-character-random-code>
313
- - "redirect_uri": <callback-url>
314
- For token refresh, *body_data* will have the attributes
315
- - "grant_type": "refresh_token"
316
- - "refresh_token": <current-refresh-token>
317
-
318
- If the operation is successful, the token data is stored in the registry.
319
- Otherwise, *errors* will contain the appropriate error message.
320
-
321
- :param registry: the registry holding the authentication data
322
- :param user_data: the user's data in the registry
323
- :param body_data: the data to send in the body of the request
324
- :param errors: incidental errors
325
- :param logger: optional logger
326
- :return: the access token obtained, or *None* if error
327
- """
328
- # initialize the return variable
329
- result: str | None = None
330
-
331
- # complete the data to send in body of request
332
- body_data["client_id"] = registry["client-id"]
333
- client_secret: str = registry["client-secret"]
334
- if client_secret:
335
- body_data["client_secret"] = client_secret
336
-
337
- # obtain the token
338
- err_msg: str | None = None
339
- url: str = registry["base-url"] + "/protocol/openid-connect/token"
340
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
341
- if logger:
342
- logger.debug(msg=f"POST '{url}', data {json.dumps(obj=body_data,
343
- ensure_ascii=False)}")
344
- try:
345
- # typical return on a token request:
346
- # {
347
- # "token_type": "Bearer",
348
- # "access_token": <str>,
349
- # "expires_in": <number-of-seconds>,
350
- # "refresh_token": <str>
351
- # }
352
- response: requests.Response = requests.post(url=url,
353
- data=body_data)
354
- if response.status_code == 200:
355
- # request succeeded
356
- if logger:
357
- logger.debug(msg=f"POST success, status {response.status_code}")
358
- reply: dict[str, Any] = response.json()
359
- result = reply.get("access_token")
360
- user_data["access-token"] = result
361
- # on token refresh, keep current refresh token if a new one is not provided
362
- user_data["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
363
- user_data["access-expiration"] = now + reply.get("expires_in")
364
- else:
365
- # request resulted in error
366
- err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
367
- if hasattr(response, "content") and response.content:
368
- err_msg += f", content '{response.content}'"
369
- if response.status_code == 400 and body_data.get("grant_type") == "refresh_token":
370
- # refresh token is no longer valid
371
- user_data["refresh-token"] = None
372
- except Exception as e:
373
- # the operation raised an exception
374
- err_msg = exc_format(exc=e,
375
- exc_info=sys.exc_info())
376
- err_msg = f"POST '{url}': error '{err_msg}'"
377
-
378
- if err_msg:
379
- if isinstance(errors, list):
380
- errors.append(err_msg)
381
- if logger:
382
- logger.error(msg=err_msg)
383
-
384
- return result
385
-
386
-
387
- def _log_init(request: Request) -> str:
388
- """
389
- Build the messages for logging the request entry.
390
-
391
- :param request: the Request object
392
- :return: the log message
393
- """
394
-
395
- params: str = json.dumps(obj=request.args,
396
- ensure_ascii=False)
397
- return f"Request {request.method}:{request.path}, params {params}"