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