pypomes-jwt 0.7.0__tar.gz → 0.7.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-jwt might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_jwt
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: A collection of Python pomes, penyeach (JWT module)
5
5
  Project-URL: Homepage, https://github.com/TheWiseCoder/PyPomes-JWT
6
6
  Project-URL: Bug Tracker, https://github.com/TheWiseCoder/PyPomes-JWT/issues
@@ -10,6 +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: cryptography>=44.0.1
13
+ Requires-Dist: cryptography>=44.0.2
14
14
  Requires-Dist: pyjwt>=2.10.1
15
15
  Requires-Dist: pypomes-core>=1.8.3
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "pypomes_jwt"
9
- version = "0.7.0"
9
+ version = "0.7.2"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -20,8 +20,9 @@ classifiers = [
20
20
  ]
21
21
  dependencies = [
22
22
  "PyJWT>=2.10.1",
23
- "cryptography>=44.0.1",
23
+ "cryptography>=44.0.2",
24
24
  "pypomes_core>=1.8.3"
25
+ # "pypomes_db>=1.9.1"
25
26
  ]
26
27
 
27
28
  [project.urls]
@@ -0,0 +1,27 @@
1
+ from .jwt_constants import (
2
+ JWT_DB_ENGINE, JWT_DB_HOST, JWT_DB_NAME,
3
+ JWT_DB_PORT, JWT_DB_USER, JWT_DB_PWD,
4
+ JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
5
+ JWT_ENCODING_KEY, JWT_DECODING_KEY
6
+ )
7
+ from .jwt_pomes import (
8
+ jwt_needed, jwt_verify_request,
9
+ jwt_get_tokens, jwt_get_claims, jwt_validate_token,
10
+ jwt_assert_access, jwt_set_access, jwt_remove_access, jwt_revoke_tokens
11
+ )
12
+
13
+ __all__ = [
14
+ # jwt_constants
15
+ "JWT_DB_ENGINE", "JWT_DB_HOST", "JWT_DB_NAME",
16
+ "JWT_DB_PORT", "JWT_DB_USER", "JWT_DB_PWD",
17
+ "JWT_ACCESS_MAX_AGE", "JWT_REFRESH_MAX_AGE",
18
+ "JWT_ENCODING_KEY", "JWT_DECODING_KEY",
19
+ # jwt_pomes
20
+ "jwt_needed", "jwt_verify_request",
21
+ "jwt_get_tokens", "jwt_get_claims", "jwt_validate_token",
22
+ "jwt_assert_access", "jwt_set_access", "jwt_remove_access", "jwt_revoke_tokens"
23
+ ]
24
+
25
+ from importlib.metadata import version
26
+ __version__ = version("pypomes_jwt")
27
+ __version_info__ = tuple(int(i) for i in __version__.split(".") if i.isdigit())
@@ -0,0 +1,76 @@
1
+ from cryptography.hazmat.primitives import serialization
2
+ from cryptography.hazmat.primitives.asymmetric import rsa
3
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
4
+ from pypomes_core import (
5
+ APP_PREFIX,
6
+ env_get_str, env_get_bytes, env_get_int, env_get_bool
7
+ )
8
+ from secrets import token_bytes
9
+ from typing import Final
10
+
11
+ # database specs for token persistence
12
+ JWT_DB_HOST: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_HOST")
13
+ JWT_DB_NAME: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_NAME")
14
+ JWT_DB_PORT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_DB_PORT")
15
+ JWT_DB_USER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_USER")
16
+ JWT_DB_PWD: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_PWD")
17
+ JWT_DB_CLIENT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_CLIENT") # for Oracle, only
18
+ JWT_DB_DRIVER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_DRIVER") # for SQLServer, only
19
+ JWT_DB_TABLE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_TABLE")
20
+ JWT_ROTATE_TOKENS: Final[bool] = env_get_bool(key=f"{APP_PREFIX}_JWT_ROTATE_TOKENS",
21
+ def_value=True)
22
+
23
+ __db_engine: str | None = env_get_str(key=f"{APP_PREFIX}_JWT_DB_ENGINE")
24
+ __rotate_tokens: bool = False
25
+ if __db_engine:
26
+ from pypomes_db import DbEngine, db_setup, db_assert_access, db_delete
27
+ from sys import stderr
28
+ if db_setup(engine=DbEngine(__db_engine),
29
+ db_name=JWT_DB_NAME,
30
+ db_user=JWT_DB_USER,
31
+ db_pwd=JWT_DB_PWD,
32
+ db_host=JWT_DB_HOST,
33
+ db_port=JWT_DB_PORT,
34
+ db_client=JWT_DB_CLIENT,
35
+ db_driver=JWT_DB_DRIVER):
36
+ __errors: list[str] = []
37
+ if not db_assert_access(errors=__errors) or \
38
+ db_delete(errors=__errors,
39
+ delete_stmt=f"DELETE FROM {JWT_DB_TABLE}") is None:
40
+ stderr.write(f"{'; '.join(__errors)}\n")
41
+ __db_engine = None
42
+ else:
43
+ stderr.write("Invalid database parameters\n")
44
+ __db_engine = None
45
+ JWT_DB_ENGINE: Final[DbEngine] = DbEngine(__db_engine) if __db_engine else None
46
+
47
+ # one of HS256, HS512, RSA256, RSA512
48
+ JWT_DEFAULT_ALGORITHM: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DEFAULT_ALGORITHM",
49
+ def_value="HS256")
50
+ # recommended: between 5 min and 1 hour (set to 5 min)
51
+ JWT_ACCESS_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCESS_MAX_AGE",
52
+ def_value=300)
53
+ # recommended: at least 2 hours (set to 24 hours)
54
+ JWT_REFRESH_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_REFRESH_MAX_AGE",
55
+ def_value=86400)
56
+
57
+ # recommended: allow the encode and decode keys to be generated anew when app starts
58
+ __encoding_key: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_ENCODE_KEY")
59
+ __decoding_key: bytes
60
+ if JWT_DEFAULT_ALGORITHM in ["HS256", "HS512"]:
61
+ if not __encoding_key:
62
+ __encoding_key = token_bytes(nbytes=32)
63
+ __decoding_key = __encoding_key
64
+ else:
65
+ __decoding_key: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_DECODE_KEY")
66
+ if not __encoding_key or not __decoding_key:
67
+ __priv_key: RSAPrivateKey = rsa.generate_private_key(public_exponent=65537,
68
+ key_size=2048)
69
+ __encoding_key = __priv_key.private_bytes(encoding=serialization.Encoding.PEM,
70
+ format=serialization.PrivateFormat.PKCS8,
71
+ encryption_algorithm=serialization.NoEncryption())
72
+ __pub_key: RSAPublicKey = __priv_key.public_key()
73
+ __decoding_key = __pub_key.public_bytes(encoding=serialization.Encoding.PEM,
74
+ format=serialization.PublicFormat.SubjectPublicKeyInfo)
75
+ JWT_ENCODING_KEY: Final[bytes] = __encoding_key
76
+ JWT_DECODING_KEY: Final[bytes] = __decoding_key
@@ -0,0 +1,341 @@
1
+ import jwt
2
+ import requests
3
+ import string
4
+ from datetime import datetime, timezone
5
+ from logging import Logger
6
+ from pypomes_core import str_random
7
+ from requests import Response
8
+ from threading import Lock
9
+ from typing import Any
10
+
11
+ from .jwt_constants import (
12
+ JWT_DEFAULT_ALGORITHM, JWT_ENCODING_KEY,
13
+ JWT_ROTATE_TOKENS, JWT_DB_ENGINE, JWT_DB_TABLE
14
+ )
15
+
16
+
17
+ class JwtData:
18
+ """
19
+ Shared JWT data for security token access.
20
+
21
+ Instance variables:
22
+ - access_lock: lock for safe multi-threading access
23
+ - access_data: dictionary holding the JWT token data, organized by account id:
24
+ {
25
+ <account-id>: {
26
+ "reference-url": # the reference URL
27
+ "remote-provider": <bool>, # whether the JWT provider is a remote server
28
+ "request-timeout": <int>, # in seconds - defaults to no timeout
29
+ "access-max-age": <int>, # in seconds - defaults to JWT_ACCESS_MAX_AGE
30
+ "refresh-max-age": <int>, # in seconds - defaults to JWT_REFRESH_MAX_AGE
31
+ "grace-interval": <int> # time to wait for token to be valid, in seconds
32
+ "token-audience": <string> # the audience the token is intended for
33
+ "token_nonce": <string> # value used to associate a client session with a token
34
+ "claims": {
35
+ "birthdate": <string>, # subject's birth date
36
+ "email": <string>, # subject's email
37
+ "gender": <string>, # subject's gender
38
+ "name": <string>, # subject's name
39
+ "roles": <List[str]>, # subject roles
40
+ "nonce": <string>, # value used to associate a Client session with a token
41
+ ...
42
+ }
43
+ },
44
+ ...
45
+ }
46
+
47
+ JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between
48
+ two parties. It is fully described in the RFC 7519, issued by the Internet Engineering Task Force
49
+ (see https://www.rfc-editor.org/rfc/rfc7519.html).
50
+ In this context, claims are pieces of information a token bears, and herein are loosely classified
51
+ as token-related and account-related. All times are UTC.
52
+
53
+ Token-related claims are mostly required claims, and convey information about the token itself:
54
+ "exp": <timestamp> # expiration time
55
+ "iat": <timestamp> # issued at
56
+ "iss": <string> # issuer (for remote providers, URL to obtain and validate the access tokens)
57
+ "jti": <string> # JWT id
58
+ "sub": <string> # subject (the account identification)
59
+ "nat": <string> # nature of token (A: access; R: refresh) - locally issued tokens, only
60
+ # optional:
61
+ "aud": <string> # token audience
62
+ "nbt": <timestamp> # not before time
63
+
64
+ Account-related claims are optional claims, and convey information about the registered account they belong to.
65
+ Alhough they can be freely specified, these are some of the most commonly used claims:
66
+ "birthdate": <string> # subject's birth date
67
+ "email": <string> # subject's email
68
+ "gender": <string> # subject's gender
69
+ "name": <string> # subject's name
70
+ "roles": <List[str]> # subject roles
71
+ "nonce": <string> # value used to associate a client session with a token
72
+ """
73
+ def __init__(self) -> None:
74
+ """
75
+ Initizalize the token access data.
76
+ """
77
+ self.access_lock: Lock = Lock()
78
+ self.access_data: dict[str, Any] = {}
79
+
80
+ def add_access(self,
81
+ account_id: str,
82
+ reference_url: str,
83
+ claims: dict[str, Any],
84
+ access_max_age: int,
85
+ refresh_max_age: int,
86
+ grace_interval: int,
87
+ token_audience: str,
88
+ token_nonce: str,
89
+ request_timeout: int,
90
+ remote_provider: bool,
91
+ logger: Logger = None) -> None:
92
+ """
93
+ Add to storage the parameters needed to produce and validate JWT tokens for *account_id*.
94
+
95
+ The parameter *claims* may contain account-related claims, only. Ideally, it should contain,
96
+ at a minimum, "birthdate", "email", "gender", "name", and "roles".
97
+ If the token provider is local, then the token-related claims are created at token issuing time.
98
+ If the token provider is remote, all claims are sent to it at token request time.
99
+
100
+ :param account_id: the account identification
101
+ :param reference_url: the reference URL (for remote providers, URL to obtain and validate the JWT tokens)
102
+ :param claims: the JWT claimset, as key-value pairs
103
+ :param access_max_age: access token duration, in seconds
104
+ :param refresh_max_age: refresh token duration, in seconds
105
+ :param grace_interval: time to wait for token to be valid, in seconds
106
+ :param token_audience: the audience the token is intended for
107
+ :param token_nonce: optional value used to associate a client session with a token
108
+ :param request_timeout: timeout for the requests to the reference URL
109
+ :param remote_provider: whether the JWT provider is a remote server
110
+ :param logger: optional logger
111
+ """
112
+ # build and store the access data for the account
113
+ with self.access_lock:
114
+ if account_id not in self.access_data:
115
+ self.access_data[account_id] = {
116
+ "reference_url": reference_url,
117
+ "access-max-age": access_max_age,
118
+ "refresh-max-age": refresh_max_age,
119
+ "grace-interval": grace_interval,
120
+ "token-audience": token_audience,
121
+ "token-nonce": token_nonce,
122
+ "request-timeout": request_timeout,
123
+ "remote-provider": remote_provider,
124
+ "claims": claims or {}
125
+ }
126
+ if logger:
127
+ logger.debug(f"JWT data added for '{account_id}'")
128
+ elif logger:
129
+ logger.warning(f"JWT data already exists for '{account_id}'")
130
+
131
+ def remove_access(self,
132
+ account_id: str,
133
+ logger: Logger) -> bool:
134
+ """
135
+ Remove from storage the access data for *account_id*.
136
+
137
+ :param account_id: the account identification
138
+ :param logger: optional logger
139
+ return: *True* if the access data was removed, *False* otherwise
140
+ """
141
+ account_data: dict[str, Any] | None
142
+ with self.access_lock:
143
+ account_data = self.access_data.pop(account_id, None)
144
+
145
+ if logger:
146
+ if account_data:
147
+ logger.debug(f"Removed JWT data for '{account_id}'")
148
+ else:
149
+ logger.warning(f"No JWT data found for '{account_id}'")
150
+
151
+ return account_data is not None
152
+
153
+ def issue_tokens(self,
154
+ account_id: str,
155
+ account_claims: dict[str, Any] = None,
156
+ logger: Logger = None) -> dict[str, Any]:
157
+ """
158
+ Issue and return the JWT access and refresh tokens for *account_id*.
159
+
160
+ Structure of the return data:
161
+ {
162
+ "access_token": <jwt-token>,
163
+ "created_in": <timestamp>,
164
+ "expires_in": <seconds-to-expiration>,
165
+ "refresh_token": <jwt-token>
166
+ }
167
+
168
+ :param account_id: the account identification
169
+ :param account_claims: if provided, may supercede registered account-related claims
170
+ :param logger: optional logger
171
+ :return: the JWT token data, or *None* if error
172
+ :raises InvalidTokenError: token is invalid
173
+ :raises InvalidKeyError: authentication key is not in the proper format
174
+ :raises ExpiredSignatureError: token and refresh period have expired
175
+ :raises InvalidSignatureError: signature does not match the one provided as part of the token
176
+ :raises ImmatureSignatureError: 'nbf' or 'iat' claim represents a timestamp in the future
177
+ :raises InvalidAudienceError: 'aud' claim does not match one of the expected audience
178
+ :raises InvalidAlgorithmError: the specified algorithm is not recognized
179
+ :raises InvalidIssuerError: 'iss' claim does not match the expected issuer
180
+ :raises InvalidIssuedAtError: 'iat' claim is non-numeric
181
+ :raises MissingRequiredClaimError: a required claim is not contained in the claimset
182
+ :raises RuntimeError: error accessing the revocation database, or
183
+ the remote JWT provider failed to return a token
184
+ """
185
+ # initialize the return variable
186
+ result: dict[str, Any] | None = None
187
+
188
+ # process the data in storage
189
+ with (self.access_lock):
190
+ account_data: dict[str, Any] = self.access_data.get(account_id)
191
+
192
+ # was the JWT data obtained ?
193
+ if account_data:
194
+ # yes, proceed
195
+ errors: list[str] = []
196
+ current_claims: dict[str, Any] = account_data.get("claims").copy()
197
+ if account_claims:
198
+ current_claims.update(account_claims)
199
+
200
+ # obtain new tokens
201
+ current_claims["jti"] = str_random(size=32,
202
+ chars=string.ascii_letters + string.digits)
203
+ current_claims["iss"] = account_data.get("reference-url")
204
+
205
+ # where is the JWT service provider ?
206
+ if account_data.get("remote-provider"):
207
+ # JWT service is being provided by a remote server
208
+ # Structure of the return data:
209
+ # {
210
+ # "access_token": <jwt-token>,
211
+ # "created_in": <timestamp>,
212
+ # "expires_in": <seconds-to-expiration>,
213
+ # "refresh_token": <jwt-token>
214
+ # ...
215
+ # }
216
+ result = _jwt_request_token(errors=errors,
217
+ reference_url=current_claims.get("iss"),
218
+ claims=current_claims,
219
+ timeout=account_data.get("request-timeout"),
220
+ logger=logger)
221
+ if errors:
222
+ raise RuntimeError(" - ".join(errors))
223
+ else:
224
+ # JWT service is being provided locally
225
+ just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
226
+ current_claims["iat"] = just_now
227
+
228
+ # retrieve the refresh token associated with the account id
229
+ refresh_token: str | None = None
230
+ if JWT_DB_ENGINE:
231
+ from pypomes_db import db_select, db_delete
232
+ if JWT_ROTATE_TOKENS:
233
+ db_delete(errors=errors,
234
+ delete_stmt=f"DELETE FROM {JWT_DB_TABLE} "
235
+ f"WHERE account_id = '{account_id}'",
236
+ logger=logger)
237
+ else:
238
+ recs: list[tuple[str]] = db_select(errors=errors,
239
+ sel_stmt=f"SELECT jwt_token FROM {JWT_DB_TABLE} "
240
+ f"WHERE account_id = '{account_id}'",
241
+ max_count=1,
242
+ logger=logger)
243
+ if recs:
244
+ refresh_token = recs[0][0]
245
+ if errors:
246
+ raise RuntimeError(" - ".join(errors))
247
+
248
+ # was it obtained ?
249
+ if not refresh_token:
250
+ # no, issue a new one
251
+ current_claims["exp"] = just_now + account_data.get("refresh-max-age")
252
+ current_claims["nat"] = "R"
253
+ # may raise an exception
254
+ refresh_token: str = jwt.encode(payload=current_claims,
255
+ key=JWT_ENCODING_KEY,
256
+ algorithm=JWT_DEFAULT_ALGORITHM)
257
+ # persist the new refresh token
258
+ if JWT_DB_ENGINE:
259
+ from pypomes_db import db_insert
260
+ db_insert(errors=errors,
261
+ insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
262
+ insert_data={"account_id": account_id,
263
+ "jwt_token": refresh_token},
264
+ logger=logger)
265
+ if errors:
266
+ raise RuntimeError(" - ".join(errors))
267
+
268
+ # issue the access token
269
+ current_claims["nat"] = "A"
270
+ current_claims["exp"] = just_now + account_data.get("access-max-age")
271
+ # may raise an exception
272
+ access_token: str = jwt.encode(payload=current_claims,
273
+ key=JWT_ENCODING_KEY,
274
+ algorithm=JWT_DEFAULT_ALGORITHM)
275
+ # return the token data
276
+ result = {
277
+ "access_token": access_token,
278
+ "created_in": current_claims.get("iat"),
279
+ "expires_in": current_claims.get("exp"),
280
+ "refresh_token": refresh_token
281
+ }
282
+ else:
283
+ # JWT access data not found
284
+ err_msg: str = f"No JWT access data found for '{account_id}'"
285
+ if logger:
286
+ logger.error(err_msg)
287
+ raise RuntimeError(err_msg)
288
+
289
+ return result
290
+
291
+
292
+ def _jwt_request_token(errors: list[str],
293
+ reference_url: str,
294
+ claims: dict[str, Any],
295
+ timeout: int = None,
296
+ logger: Logger = None) -> dict[str, Any]:
297
+ """
298
+ Obtain and return the JWT token from *reference_url*, along with its duration.
299
+
300
+ Expected structure of the return data:
301
+ {
302
+ "access_token": <jwt-token>,
303
+ "expires_in": <seconds-to-expiration>
304
+ }
305
+ It is up to the invoker to make sure that the *claims* data conform to the requirements
306
+ of the provider issuing the JWT token.
307
+
308
+ :param errors: incidental errors
309
+ :param reference_url: the reference URL for obtaining JWT tokens
310
+ :param claims: the JWT claimset, as expected by the issuing server
311
+ :param timeout: request timeout, in seconds (defaults to *None*)
312
+ :param logger: optional logger
313
+ """
314
+ # initialize the return variable
315
+ result: dict[str, Any] | None = None
316
+
317
+ # request the JWT token
318
+ if logger:
319
+ logger.debug(f"POST request JWT token to '{reference_url}'")
320
+ response: Response = requests.post(
321
+ url=reference_url,
322
+ json=claims,
323
+ timeout=timeout
324
+ )
325
+
326
+ # was the request successful ?
327
+ if response.status_code in [200, 201, 202]:
328
+ # yes, save the access token data returned
329
+ result = response.json()
330
+ if logger:
331
+ logger.debug(f"JWT token obtained: {result}")
332
+ else:
333
+ # no, report the problem
334
+ err_msg: str = f"POST request to '{reference_url}' failed: {response.reason}"
335
+ if response.text:
336
+ err_msg += f" - {response.text}"
337
+ if logger:
338
+ logger.error(err_msg)
339
+ errors.append(err_msg)
340
+
341
+ return result