pypomes-jwt 0.5.9__tar.gz → 0.6.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.
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/PKG-INFO +3 -3
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/pyproject.toml +3 -3
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/src/pypomes_jwt/__init__.py +6 -6
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/src/pypomes_jwt/jwt_data.py +82 -113
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/src/pypomes_jwt/jwt_pomes.py +100 -81
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/.gitignore +0 -0
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/LICENSE +0 -0
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/README.md +0 -0
- {pypomes_jwt-0.5.9 → pypomes_jwt-0.6.1}/src/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_jwt
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
14
|
Requires-Dist: pyjwt>=2.10.1
|
|
14
|
-
Requires-Dist:
|
|
15
|
-
Requires-Dist: pypomes-core>=1.7.1
|
|
15
|
+
Requires-Dist: pypomes-core>=1.7.7
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "pypomes_jwt"
|
|
9
|
-
version = "0.
|
|
9
|
+
version = "0.6.1"
|
|
10
10
|
authors = [
|
|
11
11
|
{ name="GT Nunes", email="wisecoder01@gmail.com" }
|
|
12
12
|
]
|
|
@@ -20,8 +20,8 @@ classifiers = [
|
|
|
20
20
|
]
|
|
21
21
|
dependencies = [
|
|
22
22
|
"PyJWT>=2.10.1",
|
|
23
|
-
"
|
|
24
|
-
"pypomes_core>=1.7.
|
|
23
|
+
"cryptography>=44.0.1",
|
|
24
|
+
"pypomes_core>=1.7.7"
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
[project.urls]
|
|
@@ -5,9 +5,9 @@ from .jwt_pomes import (
|
|
|
5
5
|
JWT_ENDPOINT_URL,
|
|
6
6
|
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
|
|
7
7
|
JWT_HS_SECRET_KEY, JWT_RSA_PRIVATE_KEY, JWT_RSA_PUBLIC_KEY,
|
|
8
|
-
jwt_needed, jwt_verify_request,
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
jwt_needed, jwt_verify_request, jwt_service,
|
|
9
|
+
jwt_get_token_claims, jwt_get_token, jwt_get_token_data,
|
|
10
|
+
jwt_assert_access, jwt_set_access, jwt_remove_access
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
__all__ = [
|
|
@@ -17,9 +17,9 @@ __all__ = [
|
|
|
17
17
|
"JWT_ENDPOINT_URL",
|
|
18
18
|
"JWT_ACCESS_MAX_AGE", "JWT_REFRESH_MAX_AGE",
|
|
19
19
|
"JWT_HS_SECRET_KEY", "JWT_RSA_PRIVATE_KEY", "JWT_RSA_PUBLIC_KEY",
|
|
20
|
-
"jwt_needed", "jwt_verify_request",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
20
|
+
"jwt_needed", "jwt_verify_request", "jwt_service",
|
|
21
|
+
"jwt_get_token_claims", "jwt_get_token", "jwt_get_token_data",
|
|
22
|
+
"jwt_assert_access", "jwt_set_access", "jwt_remove_access"
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
from importlib.metadata import version
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import jwt
|
|
2
|
-
import math
|
|
3
2
|
import requests
|
|
4
3
|
from datetime import datetime, timedelta, timezone
|
|
5
4
|
from jwt.exceptions import InvalidTokenError
|
|
@@ -18,26 +17,35 @@ class JwtData:
|
|
|
18
17
|
- access_data: list with dictionaries holding the JWT token data:
|
|
19
18
|
[
|
|
20
19
|
{
|
|
21
|
-
"
|
|
22
|
-
"exp": <timestamp>,
|
|
23
|
-
"
|
|
24
|
-
"iss": <string>,
|
|
25
|
-
"
|
|
26
|
-
"
|
|
20
|
+
"reserved-claims": { # reserved claims
|
|
21
|
+
"exp": <timestamp>, # expiration time
|
|
22
|
+
"iat": <timestamp> # issued at
|
|
23
|
+
"iss": <string>, # issuer (for remote providers, URL to obtain and validate the access tokens)
|
|
24
|
+
"jti": <string>, # JWT id
|
|
25
|
+
"sub": <string> # subject (the account identification)
|
|
26
|
+
# not used:
|
|
27
|
+
# "aud": <string> # audience
|
|
28
|
+
# "nbt": <timestamp> # not before time
|
|
27
29
|
},
|
|
28
|
-
"
|
|
30
|
+
"public-claims": {
|
|
31
|
+
"birthdate": <string>, # subject's birth date
|
|
32
|
+
"email": <string>, # subject's email
|
|
33
|
+
"gender": <string>, # subject's gender
|
|
34
|
+
"name": <string>, # subject's name
|
|
35
|
+
"roles": <List[str]> # subject roles
|
|
36
|
+
},
|
|
37
|
+
"custom-claims": { # custom claims
|
|
29
38
|
"<custom-claim-key-1>": "<custom-claim-value-1>",
|
|
30
39
|
...
|
|
31
40
|
"<custom-claim-key-n>": "<custom-claim-value-n>"
|
|
32
41
|
},
|
|
33
42
|
"control-data": { # control data
|
|
43
|
+
"remote-provider": <bool>, # whether the JWT provider is a remote server
|
|
34
44
|
"access-token": <jwt-token>, # access token
|
|
35
45
|
"algorithm": <string>, # HS256, HS512, RSA256, RSA512
|
|
36
|
-
"request-timeout": <
|
|
46
|
+
"request-timeout": <int>, # in seconds - defaults to no timeout
|
|
37
47
|
"access-max-age": <int>, # in seconds - defaults to JWT_ACCESS_MAX_AGE
|
|
38
48
|
"refresh-exp": <timestamp>, # expiration time for the refresh operation
|
|
39
|
-
"reference-url": <url>, # URL to obtain and validate the access tokens
|
|
40
|
-
"remote-provider": <bool>, # whether the JWT provider is a remote server
|
|
41
49
|
"secret-key": <bytes>, # HS secret key
|
|
42
50
|
"private-key": <bytes>, # RSA private key
|
|
43
51
|
"public-key": <bytes>, # RSA public key
|
|
@@ -54,6 +62,7 @@ class JwtData:
|
|
|
54
62
|
self.access_data: list[dict[str, dict[str, Any]]] = []
|
|
55
63
|
|
|
56
64
|
def add_access_data(self,
|
|
65
|
+
account_id: str,
|
|
57
66
|
reference_url: str,
|
|
58
67
|
claims: dict[str, Any],
|
|
59
68
|
algorithm: Literal["HS256", "HS512", "RSA256", "RSA512"],
|
|
@@ -62,33 +71,37 @@ class JwtData:
|
|
|
62
71
|
secret_key: bytes,
|
|
63
72
|
private_key: bytes,
|
|
64
73
|
public_key: bytes,
|
|
65
|
-
request_timeout:
|
|
74
|
+
request_timeout: int,
|
|
66
75
|
remote_provider: bool,
|
|
67
76
|
logger: Logger = None) -> None:
|
|
68
77
|
"""
|
|
69
|
-
Add to storage the parameters needed to
|
|
78
|
+
Add to storage the parameters needed to produce and validate JWT tokens for *account_id*.
|
|
79
|
+
|
|
80
|
+
The parameter *claims* may contain public and custom claims. Currently, the public claims supported
|
|
81
|
+
are *birthdate*, *email*, *gender*, *name*, and *roles*. Everything else are considered to be custom
|
|
82
|
+
claims, and are sent to the remote JWT provided, if applicable.
|
|
70
83
|
|
|
71
84
|
Presently, the *refresh_max_age* data is not relevant, as the authorization parameters in *claims*
|
|
72
85
|
(typically, an acess-key/secret-key pair), have been previously validated elsewhere.
|
|
73
86
|
This situation might change in the future.
|
|
74
87
|
|
|
75
|
-
:param
|
|
88
|
+
:param account_id: the account identification
|
|
89
|
+
:param reference_url: the reference URL (for remote providers, URL to obtain and validate the JWT tokens)
|
|
76
90
|
:param claims: the JWT claimset, as key-value pairs
|
|
77
91
|
:param algorithm: the algorithm used to sign the token with
|
|
78
|
-
:param access_max_age: token duration
|
|
79
|
-
:param refresh_max_age: duration for the refresh operation
|
|
92
|
+
:param access_max_age: token duration (in seconds)
|
|
93
|
+
:param refresh_max_age: duration for the refresh operation (in seconds)
|
|
80
94
|
:param secret_key: secret key for HS authentication
|
|
81
95
|
:param private_key: private key for RSA authentication
|
|
82
96
|
:param public_key: public key for RSA authentication
|
|
83
|
-
:param request_timeout: timeout for the requests to the
|
|
97
|
+
:param request_timeout: timeout for the requests to the reference URL
|
|
84
98
|
:param remote_provider: whether the JWT provider is a remote server
|
|
85
99
|
:param logger: optional logger
|
|
86
100
|
"""
|
|
87
101
|
# Do the access data already exist ?
|
|
88
|
-
if not self.
|
|
102
|
+
if not self.retrieve_access_data(account_id=account_id):
|
|
89
103
|
# no, build control data
|
|
90
104
|
control_data: dict[str, Any] = {
|
|
91
|
-
"reference-url": reference_url,
|
|
92
105
|
"algorithm": algorithm,
|
|
93
106
|
"access-max-age": access_max_age,
|
|
94
107
|
"request-timeout": request_timeout,
|
|
@@ -102,55 +115,59 @@ class JwtData:
|
|
|
102
115
|
control_data["public-key"] = public_key
|
|
103
116
|
|
|
104
117
|
# build claims
|
|
118
|
+
reserved_claims: dict[str, Any] = {
|
|
119
|
+
"sub": account_id,
|
|
120
|
+
"iss": reference_url,
|
|
121
|
+
"exp": "<numeric-UTC-datetime>",
|
|
122
|
+
"iat": "<numeric-UTC-datetime>",
|
|
123
|
+
"jti": "<jwt-id",
|
|
124
|
+
}
|
|
105
125
|
custom_claims: dict[str, Any] = {}
|
|
106
|
-
|
|
126
|
+
public_claims: dict[str, Any] = {}
|
|
107
127
|
for key, value in claims.items():
|
|
108
|
-
if key in ["
|
|
109
|
-
|
|
128
|
+
if key in ["birthdate", "email", "gender", "name", "roles"]:
|
|
129
|
+
public_claims[key] = value
|
|
110
130
|
else:
|
|
111
131
|
custom_claims[key] = value
|
|
112
|
-
standard_claims["exp"] = datetime(year=2000,
|
|
113
|
-
month=1,
|
|
114
|
-
day=1,
|
|
115
|
-
tzinfo=timezone.utc)
|
|
116
132
|
# store access data
|
|
117
133
|
item_data = {
|
|
118
134
|
"control-data": control_data,
|
|
119
|
-
"
|
|
135
|
+
"reserved-claims": reserved_claims,
|
|
136
|
+
"public-claims": public_claims,
|
|
120
137
|
"custom-claims": custom_claims
|
|
121
138
|
}
|
|
122
139
|
with self.access_lock:
|
|
123
140
|
self.access_data.append(item_data)
|
|
124
141
|
if logger:
|
|
125
|
-
logger.debug(f"JWT data added for '{
|
|
142
|
+
logger.debug(f"JWT data added for '{account_id}': {item_data}")
|
|
126
143
|
elif logger:
|
|
127
|
-
logger.warning(f"JWT data already exists for '{
|
|
144
|
+
logger.warning(f"JWT data already exists for '{account_id}'")
|
|
128
145
|
|
|
129
146
|
def remove_access_data(self,
|
|
130
|
-
|
|
147
|
+
account_id: str,
|
|
131
148
|
logger: Logger) -> None:
|
|
132
149
|
"""
|
|
133
|
-
Remove from storage the access data for *
|
|
150
|
+
Remove from storage the access data for *account_id*.
|
|
134
151
|
|
|
135
|
-
:param
|
|
152
|
+
:param account_id: the account identification
|
|
136
153
|
:param logger: optional logger
|
|
137
154
|
"""
|
|
138
155
|
# obtain the access data item in storage
|
|
139
|
-
item_data: dict[str, dict[str, Any]] = self.retrieve_access_data(
|
|
156
|
+
item_data: dict[str, dict[str, Any]] = self.retrieve_access_data(account_id=account_id,
|
|
140
157
|
logger=logger)
|
|
141
158
|
if item_data:
|
|
142
159
|
with self.access_lock:
|
|
143
160
|
self.access_data.remove(item_data)
|
|
144
161
|
if logger:
|
|
145
|
-
logger.debug(f"Removed JWT data for '{
|
|
162
|
+
logger.debug(f"Removed JWT data for '{account_id}'")
|
|
146
163
|
elif logger:
|
|
147
|
-
logger.warning(f"No JWT data found for '{
|
|
164
|
+
logger.warning(f"No JWT data found for '{account_id}'")
|
|
148
165
|
|
|
149
166
|
def get_token_data(self,
|
|
150
|
-
|
|
167
|
+
account_id: str,
|
|
151
168
|
logger: Logger = None) -> dict[str, Any]:
|
|
152
169
|
"""
|
|
153
|
-
Obtain and return the JWT token for *
|
|
170
|
+
Obtain and return the JWT token for *account_id*, along with its duration.
|
|
154
171
|
|
|
155
172
|
Structure of the return data:
|
|
156
173
|
{
|
|
@@ -158,9 +175,9 @@ class JwtData:
|
|
|
158
175
|
"expires_in": <seconds-to-expiration>
|
|
159
176
|
}
|
|
160
177
|
|
|
161
|
-
:param
|
|
178
|
+
:param account_id: the account identification
|
|
162
179
|
:param logger: optional logger
|
|
163
|
-
:return: the JWT token data, or
|
|
180
|
+
:return: the JWT token data, or *None* if error
|
|
164
181
|
:raises InvalidTokenError: token is invalid
|
|
165
182
|
:raises InvalidKeyError: authentication key is not in the proper format
|
|
166
183
|
:raises ExpiredSignatureError: token and refresh period have expired
|
|
@@ -171,69 +188,63 @@ class JwtData:
|
|
|
171
188
|
:raises InvalidIssuerError: 'iss' claim does not match the expected issuer
|
|
172
189
|
:raises InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
173
190
|
:raises MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
174
|
-
:raises RuntimeError: access data not found for the given *
|
|
191
|
+
:raises RuntimeError: access data not found for the given *account_id*, or
|
|
175
192
|
the remote JWT provider failed to return a token
|
|
176
193
|
"""
|
|
177
194
|
# declare the return variable
|
|
178
195
|
result: dict[str, Any]
|
|
179
196
|
|
|
180
197
|
# obtain the item in storage
|
|
181
|
-
item_data: dict[str, Any] = self.retrieve_access_data(
|
|
198
|
+
item_data: dict[str, Any] = self.retrieve_access_data(account_id=account_id,
|
|
182
199
|
logger=logger)
|
|
183
200
|
# was the JWT data obtained ?
|
|
184
201
|
if item_data:
|
|
185
202
|
# yes, proceed
|
|
186
203
|
control_data: dict[str, Any] = item_data.get("control-data")
|
|
204
|
+
reserved_claims: dict[str, Any] = item_data.get("reserved-claims")
|
|
187
205
|
custom_claims: dict[str, Any] = item_data.get("custom-claims")
|
|
188
|
-
|
|
189
|
-
just_now: datetime = datetime.now(tz=timezone.utc)
|
|
190
|
-
|
|
191
|
-
# is the current token still valid ?
|
|
192
|
-
if just_now > standard_claims.get("exp"):
|
|
193
|
-
# no, obtain a new token
|
|
194
|
-
reference_url: str = control_data.get("reference-url")
|
|
195
|
-
claims: dict[str, Any] = standard_claims.copy()
|
|
196
|
-
claims.update(custom_claims)
|
|
206
|
+
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
197
207
|
|
|
208
|
+
# obtain a new token, if the current token has expired
|
|
209
|
+
if just_now > reserved_claims.get("exp"):
|
|
198
210
|
# where is the locus of the JWT service provider ?
|
|
199
211
|
if control_data.get("remote-provider"):
|
|
200
212
|
# JWT service is being provided by a remote server
|
|
201
|
-
if reference_url.find("?") > 0:
|
|
202
|
-
reference_url = reference_url[:reference_url.index("?")]
|
|
203
|
-
claims.pop("exp", None)
|
|
204
213
|
errors: list[str] = []
|
|
205
214
|
result = jwt_request_token(errors=errors,
|
|
206
|
-
reference_url=
|
|
207
|
-
claims=
|
|
215
|
+
reference_url=reserved_claims.get("iss"),
|
|
216
|
+
claims=custom_claims,
|
|
208
217
|
timeout=control_data.get("request-timeout"),
|
|
209
218
|
logger=logger)
|
|
210
219
|
if result:
|
|
211
220
|
with self.access_lock:
|
|
212
221
|
control_data["access-token"] = result.get("access_token")
|
|
213
222
|
duration: int = result.get("expires_in")
|
|
214
|
-
|
|
223
|
+
reserved_claims["exp"] = just_now + duration
|
|
215
224
|
else:
|
|
216
225
|
raise RuntimeError(" - ".join(errors))
|
|
217
226
|
else:
|
|
218
227
|
# JWT service is being provided locally
|
|
219
|
-
|
|
228
|
+
reserved_claims["iat"] = just_now
|
|
229
|
+
reserved_claims["exp"] = just_now + control_data.get("access-max-age")
|
|
230
|
+
claims: dict[str, Any] = item_data.get("public-claims").copy()
|
|
231
|
+
claims.update(reserved_claims)
|
|
232
|
+
claims.update(custom_claims)
|
|
220
233
|
# may raise an exception
|
|
221
234
|
token: str = jwt.encode(payload=claims,
|
|
222
235
|
key=control_data.get("secret-key") or control_data.get("private-key"),
|
|
223
236
|
algorithm=control_data.get("algorithm"))
|
|
224
237
|
with self.access_lock:
|
|
225
238
|
control_data["access-token"] = token
|
|
226
|
-
standard_claims["exp"] = claims.get("exp")
|
|
227
239
|
|
|
228
240
|
# return the token
|
|
229
|
-
diff: timedelta = standard_claims.get("exp") - just_now - timedelta(seconds=10)
|
|
230
241
|
result = {
|
|
231
242
|
"access_token": control_data.get("access-token"),
|
|
232
|
-
"expires_in":
|
|
243
|
+
"expires_in": reserved_claims.get("exp") - just_now
|
|
233
244
|
}
|
|
234
245
|
else:
|
|
235
246
|
# JWT access data not found
|
|
236
|
-
err_msg: str = f"No JWT access data found for '{
|
|
247
|
+
err_msg: str = f"No JWT access data found for '{account_id}'"
|
|
237
248
|
if logger:
|
|
238
249
|
logger.error(err_msg)
|
|
239
250
|
raise RuntimeError(err_msg)
|
|
@@ -275,50 +286,14 @@ class JwtData:
|
|
|
275
286
|
|
|
276
287
|
return result
|
|
277
288
|
|
|
278
|
-
def assert_access_data(self,
|
|
279
|
-
reference_url: str) -> bool:
|
|
280
|
-
# noinspection HttpUrlsUsage
|
|
281
|
-
"""
|
|
282
|
-
Assert whether access data exists for *reference_url*.
|
|
283
|
-
|
|
284
|
-
For the purpose of locating access data, Protocol indication in *reference_url*
|
|
285
|
-
(typically, *http://* or *https://*), is disregarded. This guarantees
|
|
286
|
-
that processing herein will not be affected by in-transit protocol changes.
|
|
287
|
-
|
|
288
|
-
:param reference_url: the reference URL for obtaining JWT tokens
|
|
289
|
-
:return: *True" is access data is in storage, *False* otherwise
|
|
290
|
-
"""
|
|
291
|
-
# initialize the return variable
|
|
292
|
-
result: bool = False
|
|
293
|
-
|
|
294
|
-
# disregard protocol
|
|
295
|
-
if reference_url.find("://") > 0:
|
|
296
|
-
reference_url = reference_url[reference_url.index("://")+3:]
|
|
297
|
-
|
|
298
|
-
# assert the data
|
|
299
|
-
with self.access_lock:
|
|
300
|
-
for item_data in self.access_data:
|
|
301
|
-
item_url: str = item_data.get("control-data").get("reference-url")
|
|
302
|
-
if item_url.find("://") > 0:
|
|
303
|
-
item_url = item_url[item_url.index("://")+3:]
|
|
304
|
-
if reference_url == item_url:
|
|
305
|
-
result = True
|
|
306
|
-
break
|
|
307
|
-
|
|
308
|
-
return result
|
|
309
|
-
|
|
310
289
|
def retrieve_access_data(self,
|
|
311
|
-
|
|
290
|
+
account_id: str,
|
|
312
291
|
logger: Logger = None) -> dict[str, dict[str, Any]]:
|
|
313
292
|
# noinspection HttpUrlsUsage
|
|
314
293
|
"""
|
|
315
|
-
Retrieve and return the access data in storage for *
|
|
316
|
-
|
|
317
|
-
For the purpose of locating access data, Protocol indication in *reference_url*
|
|
318
|
-
(typically, *http://* or *https://*), is disregarded. This guarantees
|
|
319
|
-
that processing herein will not be affected by in-transit protocol changes.
|
|
294
|
+
Retrieve and return the access data in storage for *account_id*.
|
|
320
295
|
|
|
321
|
-
:param
|
|
296
|
+
:param account_id: the account identification
|
|
322
297
|
:param logger: optional logger
|
|
323
298
|
:return: the corresponding item in storage, or *None* if not found
|
|
324
299
|
"""
|
|
@@ -326,19 +301,11 @@ class JwtData:
|
|
|
326
301
|
result: dict[str, dict[str, Any]] | None = None
|
|
327
302
|
|
|
328
303
|
if logger:
|
|
329
|
-
logger.debug(f"Retrieve access data for
|
|
330
|
-
|
|
331
|
-
# disregard protocol
|
|
332
|
-
if reference_url.find("://") > 0:
|
|
333
|
-
reference_url = reference_url[reference_url.index("://")+3:]
|
|
334
|
-
|
|
304
|
+
logger.debug(f"Retrieve access data for account id '{account_id}'")
|
|
335
305
|
# retrieve the data
|
|
336
306
|
with self.access_lock:
|
|
337
307
|
for item_data in self.access_data:
|
|
338
|
-
|
|
339
|
-
if item_url.find("://") > 0:
|
|
340
|
-
item_url = item_url[item_url.index("://")+3:]
|
|
341
|
-
if reference_url == item_url:
|
|
308
|
+
if account_id == item_data.get("reserved-claims").get("sub"):
|
|
342
309
|
result = item_data
|
|
343
310
|
break
|
|
344
311
|
if logger:
|
|
@@ -350,7 +317,7 @@ class JwtData:
|
|
|
350
317
|
def jwt_request_token(errors: list[str],
|
|
351
318
|
reference_url: str,
|
|
352
319
|
claims: dict[str, Any],
|
|
353
|
-
timeout:
|
|
320
|
+
timeout: int = None,
|
|
354
321
|
logger: Logger = None) -> dict[str, Any]:
|
|
355
322
|
"""
|
|
356
323
|
Obtain and return the JWT token associated with *reference_url*, along with its duration.
|
|
@@ -389,7 +356,7 @@ def jwt_request_token(errors: list[str],
|
|
|
389
356
|
logger.debug(f"JWT token obtained: {result}")
|
|
390
357
|
else:
|
|
391
358
|
# no, report the problem
|
|
392
|
-
err_msg: str = f"POST request
|
|
359
|
+
err_msg: str = f"POST request to '{reference_url}' failed: {response.reason}"
|
|
393
360
|
if response.text:
|
|
394
361
|
err_msg += f" - {response.text}"
|
|
395
362
|
if logger:
|
|
@@ -418,7 +385,9 @@ def jwt_validate_token(token: str,
|
|
|
418
385
|
:raises InvalidSignatureError: signature does not match the one provided as part of the token
|
|
419
386
|
"""
|
|
420
387
|
if logger:
|
|
421
|
-
logger.debug(msg=f"
|
|
388
|
+
logger.debug(msg=f"Validate JWT token '{token}'")
|
|
422
389
|
jwt.decode(jwt=token,
|
|
423
390
|
key=key,
|
|
424
391
|
algorithms=[algorithm])
|
|
392
|
+
if logger:
|
|
393
|
+
logger.debug(msg=f"Token '{token}' is valid")
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import contextlib
|
|
2
|
+
from cryptography.hazmat.primitives import serialization
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
4
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
|
2
5
|
from flask import Request, Response, request, jsonify
|
|
3
6
|
from logging import Logger
|
|
4
|
-
from OpenSSL import crypto
|
|
5
7
|
from pypomes_core import APP_PREFIX, env_get_str, env_get_bytes, env_get_int
|
|
6
8
|
from secrets import token_bytes
|
|
7
9
|
from typing import Any, Final, Literal
|
|
@@ -15,18 +17,24 @@ JWT_ACCESS_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCESS_MAX_A
|
|
|
15
17
|
JWT_REFRESH_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_REFRESH_MAX_AGE",
|
|
16
18
|
def_value=43200)
|
|
17
19
|
JWT_HS_SECRET_KEY: Final[bytes] = env_get_bytes(key=f"{APP_PREFIX}_JWT_HS_SECRET_KEY",
|
|
18
|
-
def_value=token_bytes(32))
|
|
19
|
-
# must invoke 'jwt_service()' below
|
|
20
|
+
def_value=token_bytes(nbytes=32))
|
|
21
|
+
# the endpoint must invoke 'jwt_service()' below
|
|
20
22
|
JWT_ENDPOINT_URL: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_ENDPOINT_URL")
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
__priv_key =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
# obtain a RSA private/public key pair
|
|
25
|
+
__priv_bytes: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_RSA_PRIVATE_KEY")
|
|
26
|
+
__pub_bytes: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_RSA_PUBLIC_KEY")
|
|
27
|
+
if not __priv_bytes or not __pub_bytes:
|
|
28
|
+
__priv_key: RSAPrivateKey = rsa.generate_private_key(public_exponent=65537,
|
|
29
|
+
key_size=2048)
|
|
30
|
+
__priv_bytes = __priv_key.private_bytes(encoding=serialization.Encoding.PEM,
|
|
31
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
32
|
+
encryption_algorithm=serialization.NoEncryption())
|
|
33
|
+
__pub_key: RSAPublicKey = __priv_key.public_key()
|
|
34
|
+
__pub_bytes = __pub_key.public_bytes(encoding=serialization.Encoding.PEM,
|
|
35
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
36
|
+
JWT_RSA_PRIVATE_KEY: Final[bytes] = __priv_bytes
|
|
37
|
+
JWT_RSA_PUBLIC_KEY: Final[bytes] = __pub_bytes
|
|
30
38
|
|
|
31
39
|
# the JWT data object
|
|
32
40
|
__jwt_data: JwtData = JwtData()
|
|
@@ -49,21 +57,33 @@ def jwt_needed(func: callable) -> callable:
|
|
|
49
57
|
return wrapper
|
|
50
58
|
|
|
51
59
|
|
|
52
|
-
def
|
|
53
|
-
claims: dict[str, Any],
|
|
54
|
-
algorithm: Literal["HS256", "HS512", "RSA256", "RSA512"] = JWT_DEFAULT_ALGORITHM,
|
|
55
|
-
access_max_age: int = JWT_ACCESS_MAX_AGE,
|
|
56
|
-
refresh_max_age: int = JWT_REFRESH_MAX_AGE,
|
|
57
|
-
secret_key: bytes = JWT_HS_SECRET_KEY,
|
|
58
|
-
private_key: bytes = JWT_RSA_PRIVATE_KEY,
|
|
59
|
-
public_key: bytes = JWT_RSA_PUBLIC_KEY,
|
|
60
|
-
request_timeout: int = None,
|
|
61
|
-
remote_provider: bool = True,
|
|
62
|
-
logger: Logger = None) -> None:
|
|
60
|
+
def jwt_assert_access(account_id: str) -> bool:
|
|
63
61
|
"""
|
|
64
|
-
|
|
62
|
+
Determine whether access for *ccount_id* has been established.
|
|
65
63
|
|
|
66
|
-
:param
|
|
64
|
+
:param account_id: the account identification
|
|
65
|
+
:return: *True* if access data exists for *account_id*, *False* otherwise
|
|
66
|
+
"""
|
|
67
|
+
return __jwt_data.retrieve_access_data(account_id=account_id) is not None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def jwt_set_access(account_id: str,
|
|
71
|
+
reference_url: str,
|
|
72
|
+
claims: dict[str, Any],
|
|
73
|
+
algorithm: Literal["HS256", "HS512", "RSA256", "RSA512"] = JWT_DEFAULT_ALGORITHM,
|
|
74
|
+
access_max_age: int = JWT_ACCESS_MAX_AGE,
|
|
75
|
+
refresh_max_age: int = JWT_REFRESH_MAX_AGE,
|
|
76
|
+
secret_key: bytes = JWT_HS_SECRET_KEY,
|
|
77
|
+
private_key: bytes = JWT_RSA_PRIVATE_KEY,
|
|
78
|
+
public_key: bytes = JWT_RSA_PUBLIC_KEY,
|
|
79
|
+
request_timeout: int = None,
|
|
80
|
+
remote_provider: bool = True,
|
|
81
|
+
logger: Logger = None) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Set the data needed to obtain JWT tokens for *account_id*.
|
|
84
|
+
|
|
85
|
+
:param account_id: the account identification
|
|
86
|
+
:param reference_url: the reference URL (for remote providers, URL to obtain and validate the JWT tokens)
|
|
67
87
|
:param claims: the JWT claimset, as key-value pairs
|
|
68
88
|
:param algorithm: the authentication type
|
|
69
89
|
:param access_max_age: token duration, in seconds
|
|
@@ -71,23 +91,24 @@ def jwt_set_service_access(reference_url: str,
|
|
|
71
91
|
:param secret_key: secret key for HS authentication
|
|
72
92
|
:param private_key: private key for RSA authentication
|
|
73
93
|
:param public_key: public key for RSA authentication
|
|
74
|
-
:param request_timeout: timeout for the requests to the
|
|
94
|
+
:param request_timeout: timeout for the requests to the reference URL
|
|
75
95
|
:param remote_provider: whether the JWT provider is a remote server
|
|
76
96
|
:param logger: optional logger
|
|
77
97
|
"""
|
|
78
98
|
if logger:
|
|
79
|
-
logger.debug(msg=f"Register access data for '{
|
|
80
|
-
|
|
99
|
+
logger.debug(msg=f"Register access data for '{account_id}'")
|
|
100
|
+
|
|
101
|
+
# extract the claims provided in the reference URL's query string
|
|
81
102
|
pos: int = reference_url.find("?")
|
|
82
103
|
if pos > 0:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
claims[param.split("=")[0]] = param.split("=")[1]
|
|
104
|
+
params: list[str] = reference_url[pos+1:].split(sep="&")
|
|
105
|
+
for param in params:
|
|
106
|
+
claims[param.split("=")[0]] = param.split("=")[1]
|
|
87
107
|
reference_url = reference_url[:pos]
|
|
88
108
|
|
|
89
109
|
# register the JWT service
|
|
90
|
-
__jwt_data.add_access_data(
|
|
110
|
+
__jwt_data.add_access_data(account_id=account_id,
|
|
111
|
+
reference_url=reference_url,
|
|
91
112
|
claims=claims,
|
|
92
113
|
algorithm=algorithm,
|
|
93
114
|
access_max_age=access_max_age,
|
|
@@ -100,40 +121,40 @@ def jwt_set_service_access(reference_url: str,
|
|
|
100
121
|
logger=logger)
|
|
101
122
|
|
|
102
123
|
|
|
103
|
-
def
|
|
104
|
-
|
|
124
|
+
def jwt_remove_access(account_id: str,
|
|
125
|
+
logger: Logger = None) -> None:
|
|
105
126
|
"""
|
|
106
|
-
Remove from storage the JWT access data for *
|
|
127
|
+
Remove from storage the JWT access data for *account_id*.
|
|
107
128
|
|
|
108
|
-
:param
|
|
129
|
+
:param account_id: the account identification
|
|
109
130
|
:param logger: optional logger
|
|
110
131
|
"""
|
|
111
132
|
if logger:
|
|
112
|
-
logger.debug(msg=f"Remove access data for '{
|
|
133
|
+
logger.debug(msg=f"Remove access data for '{account_id}'")
|
|
113
134
|
|
|
114
|
-
__jwt_data.remove_access_data(
|
|
135
|
+
__jwt_data.remove_access_data(account_id=account_id,
|
|
115
136
|
logger=logger)
|
|
116
137
|
|
|
117
138
|
|
|
118
139
|
def jwt_get_token(errors: list[str],
|
|
119
|
-
|
|
140
|
+
account_id: str,
|
|
120
141
|
logger: Logger = None) -> str:
|
|
121
142
|
"""
|
|
122
|
-
Obtain and return a JWT token
|
|
143
|
+
Obtain and return a JWT token for *account_id*.
|
|
123
144
|
|
|
124
145
|
:param errors: incidental error messages
|
|
125
|
-
:param
|
|
146
|
+
:param account_id: the account identification
|
|
126
147
|
:param logger: optional logger
|
|
127
|
-
:return: the JWT token, or
|
|
148
|
+
:return: the JWT token, or *None* if an error ocurred
|
|
128
149
|
"""
|
|
129
150
|
# inicialize the return variable
|
|
130
151
|
result: str | None = None
|
|
131
152
|
|
|
132
153
|
if logger:
|
|
133
|
-
logger.debug(msg=f"Obtain a JWT token for '{
|
|
154
|
+
logger.debug(msg=f"Obtain a JWT token for '{account_id}'")
|
|
134
155
|
|
|
135
156
|
try:
|
|
136
|
-
token_data: dict[str, Any] = __jwt_data.get_token_data(
|
|
157
|
+
token_data: dict[str, Any] = __jwt_data.get_token_data(account_id=account_id,
|
|
137
158
|
logger=logger)
|
|
138
159
|
result = token_data.get("access_token")
|
|
139
160
|
if logger:
|
|
@@ -147,10 +168,10 @@ def jwt_get_token(errors: list[str],
|
|
|
147
168
|
|
|
148
169
|
|
|
149
170
|
def jwt_get_token_data(errors: list[str],
|
|
150
|
-
|
|
171
|
+
account_id: str,
|
|
151
172
|
logger: Logger = None) -> dict[str, Any]:
|
|
152
173
|
"""
|
|
153
|
-
Obtain and return the JWT token associated with *
|
|
174
|
+
Obtain and return the JWT token associated with *account_id*, along with its duration.
|
|
154
175
|
|
|
155
176
|
Structure of the return data:
|
|
156
177
|
{
|
|
@@ -159,17 +180,17 @@ def jwt_get_token_data(errors: list[str],
|
|
|
159
180
|
}
|
|
160
181
|
|
|
161
182
|
:param errors: incidental error messages
|
|
162
|
-
:param
|
|
183
|
+
:param account_id: the account identification
|
|
163
184
|
:param logger: optional logger
|
|
164
|
-
:return: the JWT token data, or
|
|
185
|
+
:return: the JWT token data, or *None* if error
|
|
165
186
|
"""
|
|
166
187
|
# inicialize the return variable
|
|
167
188
|
result: dict[str, Any] | None = None
|
|
168
189
|
|
|
169
190
|
if logger:
|
|
170
|
-
logger.debug(msg=f"Retrieve JWT token data for '{
|
|
191
|
+
logger.debug(msg=f"Retrieve JWT token data for '{account_id}'")
|
|
171
192
|
try:
|
|
172
|
-
result = __jwt_data.get_token_data(
|
|
193
|
+
result = __jwt_data.get_token_data(account_id=account_id,
|
|
173
194
|
logger=logger)
|
|
174
195
|
if logger:
|
|
175
196
|
logger.debug(msg=f"Data is '{result}'")
|
|
@@ -181,11 +202,11 @@ def jwt_get_token_data(errors: list[str],
|
|
|
181
202
|
return result
|
|
182
203
|
|
|
183
204
|
|
|
184
|
-
def
|
|
185
|
-
|
|
186
|
-
|
|
205
|
+
def jwt_get_token_claims(errors: list[str],
|
|
206
|
+
token: str,
|
|
207
|
+
logger: Logger = None) -> dict[str, Any]:
|
|
187
208
|
"""
|
|
188
|
-
Obtain and return the
|
|
209
|
+
Obtain and return the claims set of a JWT *token*.
|
|
189
210
|
|
|
190
211
|
:param errors: incidental error messages
|
|
191
212
|
:param token: the token to be inspected for claims
|
|
@@ -252,20 +273,22 @@ def jwt_verify_request(request: Request,
|
|
|
252
273
|
return result
|
|
253
274
|
|
|
254
275
|
|
|
255
|
-
def jwt_service(
|
|
276
|
+
def jwt_service(account_id: str = None,
|
|
256
277
|
service_params: dict[str, Any] = None,
|
|
257
278
|
logger: Logger = None) -> Response:
|
|
258
279
|
"""
|
|
259
280
|
Entry point for obtaining JWT tokens.
|
|
260
281
|
|
|
261
|
-
In order to be serviced, the invoker must send, as parameter *service_params* or in the body of the request
|
|
262
|
-
a JSON containing:
|
|
282
|
+
In order to be serviced, the invoker must send, as parameter *service_params* or in the body of the request:
|
|
263
283
|
{
|
|
264
|
-
"
|
|
265
|
-
"<custom-claim-key-1>": "<custom-claim-value-1>", -
|
|
284
|
+
"account-id": "<string>" - required account identification
|
|
285
|
+
"<custom-claim-key-1>": "<custom-claim-value-1>", - optional custom claims
|
|
266
286
|
...
|
|
267
287
|
"<custom-claim-key-n>": "<custom-claim-value-n>"
|
|
268
288
|
}
|
|
289
|
+
If provided, the additional custom claims will be sent to the remote provider, if applicable
|
|
290
|
+
(custom claims currently registered for the account may be overridden).
|
|
291
|
+
|
|
269
292
|
|
|
270
293
|
Structure of the return data:
|
|
271
294
|
{
|
|
@@ -273,7 +296,7 @@ def jwt_service(reference_url: str = None,
|
|
|
273
296
|
"expires_in": <seconds-to-expiration>
|
|
274
297
|
}
|
|
275
298
|
|
|
276
|
-
:param
|
|
299
|
+
:param account_id: the account identification, alternatively passed in JSON
|
|
277
300
|
:param service_params: the optional JSON containing the request parameters (defaults to JSON in body)
|
|
278
301
|
:param logger: optional logger
|
|
279
302
|
:return: the requested JWT token, along with its duration.
|
|
@@ -287,43 +310,39 @@ def jwt_service(reference_url: str = None,
|
|
|
287
310
|
msg += f" from '{request.base_url}'"
|
|
288
311
|
logger.debug(msg=msg)
|
|
289
312
|
|
|
290
|
-
#
|
|
313
|
+
# retrieve the parameters
|
|
291
314
|
# noinspection PyUnusedLocal
|
|
292
315
|
params: dict[str, Any] = service_params or {}
|
|
293
316
|
if not params:
|
|
294
317
|
with contextlib.suppress(Exception):
|
|
295
318
|
params = request.get_json()
|
|
319
|
+
if not account_id:
|
|
320
|
+
account_id = params.get("account-id")
|
|
296
321
|
|
|
297
|
-
#
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
reference_url = params.get("reference-url")
|
|
301
|
-
if reference_url:
|
|
322
|
+
# has the account been identified ?
|
|
323
|
+
if account_id:
|
|
324
|
+
# yes, proceed
|
|
302
325
|
if logger:
|
|
303
|
-
logger.debug(msg=f"
|
|
304
|
-
item_data: dict[str, dict[str, Any]] = __jwt_data.retrieve_access_data(
|
|
305
|
-
logger=logger)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
custom_claims
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
valid = False
|
|
312
|
-
break
|
|
313
|
-
|
|
314
|
-
# obtain the token data
|
|
315
|
-
if valid:
|
|
326
|
+
logger.debug(msg=f"Account identification is '{account_id}'")
|
|
327
|
+
item_data: dict[str, dict[str, Any]] = __jwt_data.retrieve_access_data(account_id=account_id,
|
|
328
|
+
logger=logger) or {}
|
|
329
|
+
custom_claims: dict[str, Any] = item_data.get("custom-claims").copy()
|
|
330
|
+
for key, value in params.items():
|
|
331
|
+
custom_claims[key] = value
|
|
332
|
+
|
|
333
|
+
# obtain the token data
|
|
316
334
|
try:
|
|
317
|
-
token_data: dict[str, Any] = __jwt_data.get_token_data(
|
|
335
|
+
token_data: dict[str, Any] = __jwt_data.get_token_data(account_id=account_id,
|
|
318
336
|
logger=logger)
|
|
319
337
|
result = jsonify(token_data)
|
|
320
338
|
except Exception as e:
|
|
321
|
-
# validation failed
|
|
339
|
+
# token validation failed
|
|
322
340
|
if logger:
|
|
323
341
|
logger.error(msg=str(e))
|
|
324
342
|
result = Response(response=str(e),
|
|
325
343
|
status=401)
|
|
326
344
|
else:
|
|
345
|
+
# no, report the problem
|
|
327
346
|
if logger:
|
|
328
347
|
logger.debug(msg=f"Invalid parameters {service_params}")
|
|
329
348
|
result = Response(response="Invalid parameters",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|