pypomes-jwt 0.8.9__py3-none-any.whl → 0.9.1__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-jwt might be problematic. Click here for more details.
- pypomes_jwt/__init__.py +7 -5
- pypomes_jwt/jwt_pomes.py +104 -48
- pypomes_jwt/{jwt_data.py → jwt_registry.py} +184 -116
- {pypomes_jwt-0.8.9.dist-info → pypomes_jwt-0.9.1.dist-info}/METADATA +2 -2
- pypomes_jwt-0.9.1.dist-info/RECORD +8 -0
- pypomes_jwt-0.8.9.dist-info/RECORD +0 -8
- {pypomes_jwt-0.8.9.dist-info → pypomes_jwt-0.9.1.dist-info}/WHEEL +0 -0
- {pypomes_jwt-0.8.9.dist-info → pypomes_jwt-0.9.1.dist-info}/licenses/LICENSE +0 -0
pypomes_jwt/__init__.py
CHANGED
|
@@ -3,13 +3,14 @@ from .jwt_constants import (
|
|
|
3
3
|
JWT_DB_PORT, JWT_DB_USER, JWT_DB_PWD,
|
|
4
4
|
JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ACCOUNT,
|
|
5
5
|
JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER, JWT_DB_COL_TOKEN,
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
JWT_ACCOUNT_LIMIT, JWT_ENCODING_KEY, JWT_DECODING_KEY,
|
|
7
|
+
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE
|
|
8
8
|
)
|
|
9
9
|
from .jwt_pomes import (
|
|
10
10
|
jwt_needed, jwt_verify_request,
|
|
11
11
|
jwt_assert_account, jwt_set_account, jwt_remove_account,
|
|
12
|
-
|
|
12
|
+
jwt_issue_token, jwt_issue_tokens, jwt_get_claims,
|
|
13
|
+
jwt_validate_token, jwt_revoke_token
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
@@ -18,12 +19,13 @@ __all__ = [
|
|
|
18
19
|
"JWT_DB_PORT", "JWT_DB_USER", "JWT_DB_PWD",
|
|
19
20
|
"JWT_DB_TABLE", "JWT_DB_COL_KID", "JWT_DB_COL_ACCOUNT",
|
|
20
21
|
"JWT_DB_COL_ALGORITHM", "JWT_DB_COL_DECODER", "JWT_DB_COL_TOKEN",
|
|
22
|
+
"JWT_ACCOUNT_LIMIT", "JWT_ENCODING_KEY", "JWT_DECODING_KEY",
|
|
21
23
|
"JWT_ACCESS_MAX_AGE", "JWT_REFRESH_MAX_AGE",
|
|
22
|
-
"JWT_ENCODING_KEY", "JWT_DECODING_KEY",
|
|
23
24
|
# jwt_pomes
|
|
24
25
|
"jwt_needed", "jwt_verify_request",
|
|
25
26
|
"jwt_assert_account", "jwt_set_account", "jwt_remove_account",
|
|
26
|
-
"
|
|
27
|
+
"jwt_issue_token", "jwt_issue_tokens", "jwt_get_claims",
|
|
28
|
+
"jwt_validate_token", "jwt_revoke_token"
|
|
27
29
|
]
|
|
28
30
|
|
|
29
31
|
from importlib.metadata import version
|
pypomes_jwt/jwt_pomes.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import base64
|
|
2
1
|
import jwt
|
|
2
|
+
from base64 import urlsafe_b64decode
|
|
3
3
|
from flask import Request, Response, request
|
|
4
4
|
from logging import Logger
|
|
5
5
|
from pypomes_db import db_select, db_delete
|
|
6
|
-
from typing import Any
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
from . import JWT_DB_COL_ACCOUNT
|
|
9
9
|
from .jwt_constants import (
|
|
@@ -11,10 +11,10 @@ from .jwt_constants import (
|
|
|
11
11
|
JWT_DEFAULT_ALGORITHM, JWT_DECODING_KEY,
|
|
12
12
|
JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER
|
|
13
13
|
)
|
|
14
|
-
from .
|
|
14
|
+
from .jwt_registry import JwtRegistry
|
|
15
15
|
|
|
16
16
|
# the JWT data object
|
|
17
|
-
|
|
17
|
+
__jwt_registry: JwtRegistry = JwtRegistry()
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def jwt_needed(func: callable) -> callable:
|
|
@@ -58,10 +58,10 @@ def jwt_verify_request(request: Request,
|
|
|
58
58
|
# yes, extract and validate the JWT access token
|
|
59
59
|
token: str = auth_header.split(" ")[1]
|
|
60
60
|
if logger:
|
|
61
|
-
logger.debug(msg="
|
|
61
|
+
logger.debug(msg="Bearer token was retrieved")
|
|
62
62
|
errors: list[str] = []
|
|
63
63
|
jwt_validate_token(errors=errors,
|
|
64
|
-
nature="A",
|
|
64
|
+
nature=["A"],
|
|
65
65
|
token=token)
|
|
66
66
|
if errors:
|
|
67
67
|
err_msg = "; ".join(errors)
|
|
@@ -85,7 +85,7 @@ def jwt_assert_account(account_id: str) -> bool:
|
|
|
85
85
|
:param account_id: the account identification
|
|
86
86
|
:return: *True* if access data exists for *account_id*, *False* otherwise
|
|
87
87
|
"""
|
|
88
|
-
return
|
|
88
|
+
return __jwt_registry.access_data.get(account_id) is not None
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
def jwt_set_account(account_id: str,
|
|
@@ -126,17 +126,17 @@ def jwt_set_account(account_id: str,
|
|
|
126
126
|
reference_url = reference_url[:pos]
|
|
127
127
|
|
|
128
128
|
# register the JWT service
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
129
|
+
__jwt_registry.add_account(account_id=account_id,
|
|
130
|
+
reference_url=reference_url,
|
|
131
|
+
claims=claims,
|
|
132
|
+
access_max_age=access_max_age,
|
|
133
|
+
refresh_max_age=refresh_max_age,
|
|
134
|
+
grace_interval=grace_interval,
|
|
135
|
+
token_audience=token_audience,
|
|
136
|
+
token_nonce=token_nonce,
|
|
137
|
+
request_timeout=request_timeout,
|
|
138
|
+
remote_provider=remote_provider,
|
|
139
|
+
logger=logger)
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
def jwt_remove_account(account_id: str,
|
|
@@ -151,23 +151,26 @@ def jwt_remove_account(account_id: str,
|
|
|
151
151
|
if logger:
|
|
152
152
|
logger.debug(msg=f"Remove access data for '{account_id}'")
|
|
153
153
|
|
|
154
|
-
return
|
|
155
|
-
|
|
154
|
+
return __jwt_registry.remove_account(account_id=account_id,
|
|
155
|
+
logger=logger)
|
|
156
156
|
|
|
157
157
|
|
|
158
158
|
def jwt_validate_token(errors: list[str] | None,
|
|
159
159
|
token: str,
|
|
160
|
-
nature:
|
|
160
|
+
nature: list[str] = None,
|
|
161
161
|
account_id: str = None,
|
|
162
162
|
logger: Logger = None) -> dict[str, Any] | None:
|
|
163
163
|
"""
|
|
164
164
|
Verify if *token* ia a valid JWT token.
|
|
165
165
|
|
|
166
166
|
Raise an appropriate exception if validation failed.
|
|
167
|
+
if *nature* is provided, it is checked whether *token* has been locally issued and is of a appropriate nature.
|
|
168
|
+
A token issued locally has the header claim *kid* starting with "A" (for *Access*) or "R" (for *Refresh*),
|
|
169
|
+
followed by its id in the token database.
|
|
167
170
|
|
|
168
171
|
:param errors: incidental error messages
|
|
169
172
|
:param token: the token to be validated
|
|
170
|
-
:param nature:
|
|
173
|
+
:param nature: one of more prefixes identifying the nature of locally issued tokens
|
|
171
174
|
:param account_id: optionally, validate the token's account owner
|
|
172
175
|
:param logger: optional logger
|
|
173
176
|
:return: The token's claims (header and payload) if if is valid, *None* otherwise
|
|
@@ -179,17 +182,17 @@ def jwt_validate_token(errors: list[str] | None,
|
|
|
179
182
|
|
|
180
183
|
# extract needed data from token header
|
|
181
184
|
token_header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
|
|
182
|
-
token_kid:
|
|
185
|
+
token_kid: str = token_header.get("kid")
|
|
183
186
|
token_alg: str | None = None
|
|
184
187
|
token_decoder: bytes | None = None
|
|
185
188
|
op_errors: list[str] = []
|
|
186
189
|
|
|
187
190
|
# retrieve token data from database
|
|
188
|
-
if
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
where_data: dict[str,
|
|
191
|
+
if nature and not (token_kid and token_kid[0:1] in nature):
|
|
192
|
+
op_errors.append("Invalid token")
|
|
193
|
+
elif token_kid and len(token_kid) > 1 and token_kid[0:1].isupper() and token[1:].isdigit():
|
|
194
|
+
# token was likely issued locally
|
|
195
|
+
where_data: dict[str, Any] = {JWT_DB_COL_KID: int(token_kid[1:])}
|
|
193
196
|
if account_id:
|
|
194
197
|
where_data[JWT_DB_COL_ACCOUNT] = account_id
|
|
195
198
|
recs: list[tuple[str]] = db_select(errors=op_errors,
|
|
@@ -199,7 +202,7 @@ def jwt_validate_token(errors: list[str] | None,
|
|
|
199
202
|
logger=logger)
|
|
200
203
|
if recs:
|
|
201
204
|
token_alg = recs[0][0]
|
|
202
|
-
token_decoder =
|
|
205
|
+
token_decoder = urlsafe_b64decode(recs[0][1])
|
|
203
206
|
else:
|
|
204
207
|
op_errors.append("Invalid token")
|
|
205
208
|
else:
|
|
@@ -215,9 +218,7 @@ def jwt_validate_token(errors: list[str] | None,
|
|
|
215
218
|
# ExpiredSignatureError: token and refresh period have expired
|
|
216
219
|
# InvalidSignatureError: signature does not match the one provided as part of the token
|
|
217
220
|
# ImmatureSignatureError: 'nbf' or 'iat' claim represents a timestamp in the future
|
|
218
|
-
# InvalidAudienceError: 'aud' claim does not match one of the expected audience
|
|
219
221
|
# InvalidAlgorithmError: the specified algorithm is not recognized
|
|
220
|
-
# InvalidIssuerError: 'iss' claim does not match the expected issuer
|
|
221
222
|
# InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
222
223
|
# MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
223
224
|
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
@@ -227,7 +228,7 @@ def jwt_validate_token(errors: list[str] | None,
|
|
|
227
228
|
"verify_nbf": True
|
|
228
229
|
},
|
|
229
230
|
key=token_decoder,
|
|
230
|
-
require=["exp"],
|
|
231
|
+
require=["iat", "iss", "exp", "sub"],
|
|
231
232
|
algorithms=token_alg)
|
|
232
233
|
if account_id and payload.get("sub") != account_id:
|
|
233
234
|
op_errors.append("Token does not belong to account")
|
|
@@ -275,15 +276,15 @@ def jwt_revoke_token(errors: list[str] | None,
|
|
|
275
276
|
op_errors: list[str] = []
|
|
276
277
|
token_claims: dict[str, Any] = jwt_validate_token(errors=op_errors,
|
|
277
278
|
token=refresh_token,
|
|
278
|
-
nature="R",
|
|
279
|
+
nature=["A", "R"],
|
|
279
280
|
account_id=account_id,
|
|
280
281
|
logger=logger)
|
|
281
282
|
if not op_errors:
|
|
282
|
-
token_kid:
|
|
283
|
+
token_kid: str = token_claims["header"].get("kid")
|
|
283
284
|
db_delete(errors=op_errors,
|
|
284
285
|
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
285
286
|
where_data={
|
|
286
|
-
JWT_DB_COL_KID: token_kid,
|
|
287
|
+
JWT_DB_COL_KID: int(token_kid[1:]),
|
|
287
288
|
JWT_DB_COL_ACCOUNT: account_id
|
|
288
289
|
},
|
|
289
290
|
logger=logger)
|
|
@@ -298,13 +299,66 @@ def jwt_revoke_token(errors: list[str] | None,
|
|
|
298
299
|
return result
|
|
299
300
|
|
|
300
301
|
|
|
301
|
-
def
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
302
|
+
def jwt_issue_token(errors: list[str] | None,
|
|
303
|
+
account_id: str,
|
|
304
|
+
nature: str,
|
|
305
|
+
duration: int,
|
|
306
|
+
claims: dict[str, Any],
|
|
307
|
+
grace_interval: int = None,
|
|
308
|
+
logger: Logger = None) -> str:
|
|
309
|
+
"""
|
|
310
|
+
Issue or refresh, and return, a JWT token associated with *account_id*, of the specified *nature*.
|
|
311
|
+
|
|
312
|
+
The parameter *nature* must be a single uppercase, unaccented letter, different from "A"
|
|
313
|
+
(used to indicate *access* tokens) and *R* (used to indicate *refresh* tokens).
|
|
314
|
+
The parameter *duration* specifies the token's validity interval (at least 60 seconds).
|
|
315
|
+
The token's *claims* should contain the claim *iss*.
|
|
316
|
+
|
|
317
|
+
:param errors: incidental error messages
|
|
318
|
+
:param account_id: the account identification
|
|
319
|
+
:param nature: the token's nature (a single uppercase, unaccented letter different from "A" and "R")
|
|
320
|
+
:param duration: the number of seconds for the token to remain valid (at least 60 seconds)
|
|
321
|
+
:param claims: the token's claims (must contain at least the claim "iss")
|
|
322
|
+
:param grace_interval: optional interval for the token to become active (in seconds)
|
|
323
|
+
:param logger: optional logger
|
|
324
|
+
:return: the JWT token data, or *None* if error
|
|
306
325
|
"""
|
|
307
|
-
|
|
326
|
+
# inicialize the return variable
|
|
327
|
+
result: str | None = None
|
|
328
|
+
|
|
329
|
+
if logger:
|
|
330
|
+
logger.debug(msg=f"Issue a JWT token for '{account_id}'")
|
|
331
|
+
op_errors: list[str] = []
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
result = __jwt_registry.issue_token(account_id=account_id,
|
|
335
|
+
nature=nature,
|
|
336
|
+
duration=duration,
|
|
337
|
+
claims=claims,
|
|
338
|
+
grace_interval=grace_interval,
|
|
339
|
+
logger=logger)
|
|
340
|
+
if logger:
|
|
341
|
+
logger.debug(msg=f"Token is '{result}'")
|
|
342
|
+
except Exception as e:
|
|
343
|
+
# token issuing failed
|
|
344
|
+
op_errors.append(str(e))
|
|
345
|
+
|
|
346
|
+
if op_errors:
|
|
347
|
+
if logger:
|
|
348
|
+
logger.error("; ".join(op_errors))
|
|
349
|
+
if isinstance(errors, list):
|
|
350
|
+
errors.extend(op_errors)
|
|
351
|
+
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def jwt_issue_tokens(errors: list[str] | None,
|
|
356
|
+
account_id: str,
|
|
357
|
+
account_claims: dict[str, Any] = None,
|
|
358
|
+
refresh_token: str = None,
|
|
359
|
+
logger: Logger = None) -> dict[str, Any]:
|
|
360
|
+
"""
|
|
361
|
+
Issue the JWT tokens associated with *account_id*, for access and refresh operations.
|
|
308
362
|
|
|
309
363
|
If *refresh_token* is provided, its claims are used on issuing the new tokens,
|
|
310
364
|
and claims in *account_claims*, if any, are ignored.
|
|
@@ -328,13 +382,14 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
328
382
|
result: dict[str, Any] | None = None
|
|
329
383
|
|
|
330
384
|
if logger:
|
|
331
|
-
logger.debug(msg=f"
|
|
385
|
+
logger.debug(msg=f"Return JWT token data for '{account_id}'")
|
|
332
386
|
op_errors: list[str] = []
|
|
387
|
+
|
|
388
|
+
# verify whether this refresh token is legitimate
|
|
333
389
|
if refresh_token:
|
|
334
|
-
# verify whether this refresh token is legitimate
|
|
335
390
|
account_claims = (jwt_validate_token(errors=op_errors,
|
|
336
391
|
token=refresh_token,
|
|
337
|
-
nature="R",
|
|
392
|
+
nature=["R"],
|
|
338
393
|
account_id=account_id,
|
|
339
394
|
logger=logger) or {}).get("payload")
|
|
340
395
|
if account_claims:
|
|
@@ -343,11 +398,12 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
343
398
|
account_claims.pop("iss", None)
|
|
344
399
|
account_claims.pop("exp", None)
|
|
345
400
|
account_claims.pop("nbt", None)
|
|
401
|
+
|
|
346
402
|
if not op_errors:
|
|
347
403
|
try:
|
|
348
|
-
result =
|
|
349
|
-
|
|
350
|
-
|
|
404
|
+
result = __jwt_registry.issue_tokens(account_id=account_id,
|
|
405
|
+
account_claims=account_claims,
|
|
406
|
+
logger=logger)
|
|
351
407
|
if logger:
|
|
352
408
|
logger.debug(msg=f"Token data is '{result}'")
|
|
353
409
|
except Exception as e:
|
|
@@ -380,7 +436,7 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
380
436
|
"header": {
|
|
381
437
|
"alg": "RS256",
|
|
382
438
|
"typ": "JWT",
|
|
383
|
-
"kid": "
|
|
439
|
+
"kid": "A1234"
|
|
384
440
|
},
|
|
385
441
|
"payload": {
|
|
386
442
|
"valid-from": <YYYY-MM-DDThh:mm:ss+00:00>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import base64
|
|
2
1
|
import jwt
|
|
3
2
|
import requests
|
|
4
3
|
import string
|
|
5
4
|
import sys
|
|
5
|
+
from base64 import urlsafe_b64encode
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
7
|
from logging import Logger
|
|
8
8
|
from pypomes_core import str_random
|
|
@@ -18,9 +18,9 @@ from .jwt_constants import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
class
|
|
21
|
+
class JwtRegistry:
|
|
22
22
|
"""
|
|
23
|
-
Shared JWT
|
|
23
|
+
Shared JWT registry for security token access.
|
|
24
24
|
|
|
25
25
|
Instance variables:
|
|
26
26
|
- access_lock: lock for safe multi-threading access
|
|
@@ -33,11 +33,13 @@ class JwtData:
|
|
|
33
33
|
"access-max-age": <int>, # in seconds - defaults to JWT_ACCESS_MAX_AGE
|
|
34
34
|
"refresh-max-age": <int>, # in seconds - defaults to JWT_REFRESH_MAX_AGE
|
|
35
35
|
"grace-interval": <int> # time to wait for token to be valid, in seconds
|
|
36
|
+
# optional
|
|
36
37
|
"token-audience": <string> # the audience the token is intended for
|
|
37
38
|
"token_nonce": <string> # value used to associate a client session with a token
|
|
38
39
|
"claims": {
|
|
39
40
|
"valid-from": <string> # token's start (<YYYY-MM-DDThh:mm:ss+00:00>)
|
|
40
41
|
"valid-until": <string> # token's finish (<YYYY-MM-DDThh:mm:ss+00:00>)
|
|
42
|
+
# optional
|
|
41
43
|
"birthdate": <string>, # subject's birth date
|
|
42
44
|
"email": <string>, # subject's email
|
|
43
45
|
"gender": <string>, # subject's gender
|
|
@@ -57,13 +59,13 @@ class JwtData:
|
|
|
57
59
|
as token-related and account-related. All times are UTC.
|
|
58
60
|
|
|
59
61
|
Token-related claims are mostly required claims, and convey information about the token itself:
|
|
62
|
+
# required
|
|
60
63
|
"exp": <timestamp> # expiration time
|
|
61
64
|
"iat": <timestamp> # issued at
|
|
62
65
|
"iss": <string> # issuer (for remote providers, URL to obtain and validate the access tokens)
|
|
63
66
|
"jti": <string> # JWT id
|
|
64
67
|
"sub": <string> # subject (the account identification)
|
|
65
|
-
|
|
66
|
-
# optional:
|
|
68
|
+
# optional
|
|
67
69
|
"aud": <string> # token audience
|
|
68
70
|
"nbt": <timestamp> # not before time
|
|
69
71
|
|
|
@@ -71,18 +73,20 @@ class JwtData:
|
|
|
71
73
|
Alhough they can be freely specified, these are some of the most commonly used claims:
|
|
72
74
|
"valid-from": <string> # token's start (<YYYY-MM-DDThh:mm:ss+00:00>)
|
|
73
75
|
"valid-until": <string> # token's finish (<YYYY-MM-DDThh:mm:ss+00.00>)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
"birthdate": <string> # subject's birth date
|
|
77
|
+
"email": <string> # subject's email
|
|
78
|
+
"gender": <string> # subject's gender
|
|
79
|
+
"name": <string> # subject's name
|
|
80
|
+
"roles": <List[str]> # subject roles
|
|
81
|
+
"nonce": <string> # value used to associate a client session with a token
|
|
80
82
|
|
|
81
83
|
The token header has these items:
|
|
82
84
|
"alg": <string> # the algorithm used to sign the token (one of 'HS256', 'HS512', 'RSA256', 'RSA512')
|
|
83
85
|
"typ": <string> # the token type (fixed to 'JWT'
|
|
84
|
-
"kid": <string> # a reference to the
|
|
85
|
-
|
|
86
|
+
"kid": <string> # a reference to the token type and the key to its location in the token database
|
|
87
|
+
|
|
88
|
+
If issued by the local server, "kid" holds the key to the corresponding record in the token database.
|
|
89
|
+
It starts with *A* for (*Access*) or *R* (for *Refresh*) followed its integer key.
|
|
86
90
|
"""
|
|
87
91
|
def __init__(self) -> None:
|
|
88
92
|
"""
|
|
@@ -170,6 +174,70 @@ class JwtData:
|
|
|
170
174
|
|
|
171
175
|
return account_data is not None
|
|
172
176
|
|
|
177
|
+
def issue_token(self,
|
|
178
|
+
account_id: str,
|
|
179
|
+
nature: str,
|
|
180
|
+
duration: int,
|
|
181
|
+
claims: dict[str, Any],
|
|
182
|
+
grace_interval: int = None,
|
|
183
|
+
logger: Logger = None) -> str:
|
|
184
|
+
"""
|
|
185
|
+
Issue an return a JWT token associated with *account_id*.
|
|
186
|
+
|
|
187
|
+
The parameter *nature* must be a single uppercase, unaccented letter, different from "A"
|
|
188
|
+
(used to indicate *access* tokens) and *R* (used to indicate *refresh* tokens).
|
|
189
|
+
The parameter *duration* specifies the token's validity interval (at least 60 seconds).
|
|
190
|
+
The token's *claims* should contain the claim *iss*.
|
|
191
|
+
|
|
192
|
+
:param account_id: the account identification
|
|
193
|
+
:param nature: the token's nature (a single uppercase, unaccented letter different from "A" and "R")
|
|
194
|
+
:param duration: the number of seconds for the token to remain valid (at least 60 seconds)
|
|
195
|
+
:param claims: the token's claims (must contain at least the claim "iss")
|
|
196
|
+
:param grace_interval: optional interval for the token to become active (in seconds)
|
|
197
|
+
:param logger: optional logger
|
|
198
|
+
:return: the JWT token
|
|
199
|
+
:raises RuntimeError: invalid parameter
|
|
200
|
+
"""
|
|
201
|
+
# validate some parameters
|
|
202
|
+
err_msg: str | None = None
|
|
203
|
+
if not isinstance(nature, str) or \
|
|
204
|
+
len(nature) != 1 or nature < "A" or nature > "Z":
|
|
205
|
+
err_msg: str = f"Invalid nature '{nature}'"
|
|
206
|
+
elif not isinstance(claims, dict) or "iss" not in claims:
|
|
207
|
+
err_msg = f"invalid claims '{claims}'"
|
|
208
|
+
elif not isinstance(duration, int) or duration < 60:
|
|
209
|
+
err_msg = f"Invalid duration '{duration}'"
|
|
210
|
+
if err_msg:
|
|
211
|
+
if logger:
|
|
212
|
+
logger.error(err_msg)
|
|
213
|
+
raise RuntimeError(err_msg)
|
|
214
|
+
|
|
215
|
+
# make sure account data exists
|
|
216
|
+
self.__get_account_data(account_id=account_id,
|
|
217
|
+
logger=logger)
|
|
218
|
+
# issue the token
|
|
219
|
+
current_claims: dict[str, Any] = claims.copy()
|
|
220
|
+
current_claims["jti"] = str_random(size=32,
|
|
221
|
+
chars=string.ascii_letters + string.digits)
|
|
222
|
+
current_claims["sub"] = account_id
|
|
223
|
+
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
224
|
+
current_claims["iat"] = just_now
|
|
225
|
+
if grace_interval:
|
|
226
|
+
current_claims["nbf"] = just_now + grace_interval
|
|
227
|
+
current_claims["valid-from"] = datetime.fromtimestamp(timestamp=current_claims["nbf"],
|
|
228
|
+
tz=timezone.utc).isoformat()
|
|
229
|
+
else:
|
|
230
|
+
current_claims["valid-from"] = datetime.fromtimestamp(timestamp=current_claims["iat"],
|
|
231
|
+
tz=timezone.utc).isoformat()
|
|
232
|
+
current_claims["exp"] = just_now + duration
|
|
233
|
+
current_claims["valid-until"] = datetime.fromtimestamp(timestamp=current_claims["exp"],
|
|
234
|
+
tz=timezone.utc).isoformat()
|
|
235
|
+
# may raise an exception
|
|
236
|
+
return jwt.encode(payload=current_claims,
|
|
237
|
+
key=JWT_ENCODING_KEY,
|
|
238
|
+
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
239
|
+
headers={"kid": nature})
|
|
240
|
+
|
|
173
241
|
def issue_tokens(self,
|
|
174
242
|
account_id: str,
|
|
175
243
|
account_claims: dict[str, Any] = None,
|
|
@@ -188,118 +256,118 @@ class JwtData:
|
|
|
188
256
|
:param account_id: the account identification
|
|
189
257
|
:param account_claims: if provided, may supercede registered account-related claims
|
|
190
258
|
:param logger: optional logger
|
|
191
|
-
:return: the JWT token data
|
|
192
|
-
:raises
|
|
193
|
-
:raises InvalidKeyError: authentication key is not in the proper format
|
|
194
|
-
:raises ExpiredSignatureError: token and refresh period have expired
|
|
195
|
-
:raises InvalidSignatureError: signature does not match the one provided as part of the token
|
|
196
|
-
:raises ImmatureSignatureError: 'nbf' or 'iat' claim represents a timestamp in the future
|
|
197
|
-
:raises InvalidAudienceError: 'aud' claim does not match one of the expected audience
|
|
198
|
-
:raises InvalidAlgorithmError: the specified algorithm is not recognized
|
|
199
|
-
:raises InvalidIssuerError: 'iss' claim does not match the expected issuer
|
|
200
|
-
:raises InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
201
|
-
:raises MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
202
|
-
:raises RuntimeError: error accessing the token database
|
|
259
|
+
:return: the JWT token data
|
|
260
|
+
:raises RuntimeError: invalid account id, or error accessing the token database
|
|
203
261
|
"""
|
|
204
262
|
# initialize the return variable
|
|
205
263
|
result: dict[str, Any] | None = None
|
|
206
264
|
|
|
207
265
|
# process the data in storage
|
|
208
266
|
with (self.access_lock):
|
|
209
|
-
account_data: dict[str, Any] = self.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if errors:
|
|
240
|
-
raise RuntimeError("; ".join(errors))
|
|
267
|
+
account_data: dict[str, Any] = self.__get_account_data(account_id=account_id,
|
|
268
|
+
logger=logger)
|
|
269
|
+
current_claims: dict[str, Any] = account_data.get("claims").copy()
|
|
270
|
+
if account_claims:
|
|
271
|
+
current_claims.update(account_claims)
|
|
272
|
+
current_claims["jti"] = str_random(size=32,
|
|
273
|
+
chars=string.ascii_letters + string.digits)
|
|
274
|
+
current_claims["sub"] = account_id
|
|
275
|
+
current_claims["iss"] = account_data.get("reference-url")
|
|
276
|
+
errors: list[str] = []
|
|
277
|
+
|
|
278
|
+
# where is the JWT service provider ?
|
|
279
|
+
if account_data.get("remote-provider"):
|
|
280
|
+
# JWT service is being provided by a remote server
|
|
281
|
+
result = _jwt_request_token(errors=errors,
|
|
282
|
+
reference_url=current_claims.get("iss"),
|
|
283
|
+
claims=current_claims,
|
|
284
|
+
timeout=account_data.get("request-timeout"),
|
|
285
|
+
logger=logger)
|
|
286
|
+
if errors:
|
|
287
|
+
raise RuntimeError("; ".join(errors))
|
|
288
|
+
else:
|
|
289
|
+
# JWT service is being provided locally
|
|
290
|
+
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
291
|
+
current_claims["iat"] = just_now
|
|
292
|
+
grace_interval = account_data.get("grace-interval")
|
|
293
|
+
if grace_interval:
|
|
294
|
+
current_claims["nbf"] = just_now + grace_interval
|
|
295
|
+
current_claims["valid-from"] = datetime.fromtimestamp(timestamp=current_claims["nbf"],
|
|
296
|
+
tz=timezone.utc).isoformat()
|
|
241
297
|
else:
|
|
242
|
-
# JWT service is being provided locally
|
|
243
|
-
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
244
|
-
current_claims["iat"] = just_now
|
|
245
|
-
|
|
246
|
-
# issue a candidate refresh token first, and persist it
|
|
247
|
-
current_claims["exp"] = just_now + account_data.get("refresh-max-age")
|
|
248
298
|
current_claims["valid-from"] = datetime.fromtimestamp(timestamp=current_claims["iat"],
|
|
249
299
|
tz=timezone.utc).isoformat()
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
300
|
+
# issue a candidate refresh token first, and persist it
|
|
301
|
+
current_claims["exp"] = just_now + account_data.get("refresh-max-age")
|
|
302
|
+
current_claims["valid-until"] = datetime.fromtimestamp(timestamp=current_claims["exp"],
|
|
303
|
+
tz=timezone.utc).isoformat()
|
|
304
|
+
# may raise an exception
|
|
305
|
+
refresh_token: str = jwt.encode(payload=current_claims,
|
|
306
|
+
key=JWT_ENCODING_KEY,
|
|
307
|
+
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
308
|
+
headers={"kid": "R0"})
|
|
309
|
+
# obtain a DB connection (may raise an exception)
|
|
310
|
+
db_conn: Any = db_connect(errors=errors,
|
|
311
|
+
logger=logger)
|
|
312
|
+
# persist the candidate token (may raise an exception)
|
|
313
|
+
token_id: int = _jwt_persist_token(errors=errors,
|
|
314
|
+
account_id=account_id,
|
|
315
|
+
jwt_token=refresh_token,
|
|
316
|
+
db_conn=db_conn,
|
|
317
|
+
logger=logger)
|
|
318
|
+
# issue the definitive refresh token
|
|
319
|
+
refresh_token = jwt.encode(payload=current_claims,
|
|
320
|
+
key=JWT_ENCODING_KEY,
|
|
321
|
+
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
322
|
+
headers={"kid": f"R{token_id}"})
|
|
323
|
+
# persist it
|
|
324
|
+
db_update(errors=errors,
|
|
325
|
+
update_stmt=f"UPDATE {JWT_DB_TABLE}",
|
|
326
|
+
update_data={JWT_DB_COL_TOKEN: refresh_token},
|
|
327
|
+
where_data={JWT_DB_COL_KID: token_id},
|
|
328
|
+
connection=db_conn,
|
|
329
|
+
logger=logger)
|
|
330
|
+
# commit the transaction
|
|
331
|
+
db_commit(errors=errors,
|
|
332
|
+
connection=db_conn,
|
|
333
|
+
logger=logger)
|
|
334
|
+
if errors:
|
|
335
|
+
raise RuntimeError("; ".join(errors))
|
|
336
|
+
|
|
337
|
+
# issue the access token
|
|
338
|
+
current_claims["exp"] = just_now + account_data.get("access-max-age")
|
|
339
|
+
# may raise an exception
|
|
340
|
+
access_token: str = jwt.encode(payload=current_claims,
|
|
267
341
|
key=JWT_ENCODING_KEY,
|
|
268
342
|
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
269
|
-
headers={"kid":
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
else:
|
|
298
|
-
# JWT access data not found
|
|
299
|
-
err_msg: str = f"No JWT access data found for '{account_id}'"
|
|
300
|
-
if logger:
|
|
301
|
-
logger.error(err_msg)
|
|
302
|
-
raise RuntimeError(err_msg)
|
|
343
|
+
headers={"kid": f"A{token_id}"})
|
|
344
|
+
# return the token data
|
|
345
|
+
result = {
|
|
346
|
+
"access_token": access_token,
|
|
347
|
+
"created_in": current_claims.get("iat"),
|
|
348
|
+
"expires_in": current_claims.get("exp"),
|
|
349
|
+
"refresh_token": refresh_token
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
def __get_account_data(self,
|
|
355
|
+
account_id: str,
|
|
356
|
+
logger: Logger = None) -> dict[str, Any]:
|
|
357
|
+
"""
|
|
358
|
+
Retrieve the JWT access data associated with *account_id*.
|
|
359
|
+
|
|
360
|
+
:return: the JWT access data associated with *account_id*
|
|
361
|
+
:raises RuntimeError: No JWT access data exists for *account_id*
|
|
362
|
+
"""
|
|
363
|
+
# retrieve the access data
|
|
364
|
+
result: dict[str, Any] = self.access_data.get(account_id)
|
|
365
|
+
if not result:
|
|
366
|
+
# JWT access data not found
|
|
367
|
+
err_msg: str = f"No JWT access data found for '{account_id}'"
|
|
368
|
+
if logger:
|
|
369
|
+
logger.error(err_msg)
|
|
370
|
+
raise RuntimeError(err_msg)
|
|
303
371
|
|
|
304
372
|
return result
|
|
305
373
|
|
|
@@ -379,7 +447,7 @@ def _jwt_persist_token(errors: list[str],
|
|
|
379
447
|
:param db_conn: the database connection to use
|
|
380
448
|
:param logger: optional logger
|
|
381
449
|
:return: the storage id of the inserted token
|
|
382
|
-
:raises RuntimeError: error accessing the
|
|
450
|
+
:raises RuntimeError: error accessing the token database
|
|
383
451
|
"""
|
|
384
452
|
from pypomes_db import db_select, db_insert, db_delete
|
|
385
453
|
from .jwt_pomes import jwt_get_claims
|
|
@@ -453,7 +521,7 @@ def _jwt_persist_token(errors: list[str],
|
|
|
453
521
|
insert_data={JWT_DB_COL_ACCOUNT: account_id,
|
|
454
522
|
JWT_DB_COL_TOKEN: jwt_token,
|
|
455
523
|
JWT_DB_COL_ALGORITHM: JWT_DEFAULT_ALGORITHM,
|
|
456
|
-
JWT_DB_COL_DECODER:
|
|
524
|
+
JWT_DB_COL_DECODER: urlsafe_b64encode(JWT_DECODING_KEY).decode()},
|
|
457
525
|
connection=db_conn,
|
|
458
526
|
logger=logger)
|
|
459
527
|
if errors:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_jwt
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -13,4 +13,4 @@ Requires-Python: >=3.12
|
|
|
13
13
|
Requires-Dist: cryptography>=44.0.2
|
|
14
14
|
Requires-Dist: pyjwt>=2.10.1
|
|
15
15
|
Requires-Dist: pypomes-core>=1.8.5
|
|
16
|
-
Requires-Dist: pypomes-db>=1.9.
|
|
16
|
+
Requires-Dist: pypomes-db>=1.9.7
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pypomes_jwt/__init__.py,sha256=6AISZOs7JP695PnMSsYyKfoiMXvSzXffkv3nKw7qA1A,1356
|
|
2
|
+
pypomes_jwt/jwt_constants.py,sha256=IQV39AiZKGuU8XxZBgJ-KJZQZ_mmnxyOnRZeuxlqDRk,4045
|
|
3
|
+
pypomes_jwt/jwt_pomes.py,sha256=qm8COn0vPts-I0n5pnA_5phm-5wkV82dicAf3bvQ8R8,19734
|
|
4
|
+
pypomes_jwt/jwt_registry.py,sha256=MWJy1bxXdgVySmBYJCdK_721U6tJXk3BHrYVChLjCR8,25613
|
|
5
|
+
pypomes_jwt-0.9.1.dist-info/METADATA,sha256=b5EpwtK-c0JCi3QjRt6W_QoQXg8nYkUjJHbD0pTGgF4,632
|
|
6
|
+
pypomes_jwt-0.9.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
pypomes_jwt-0.9.1.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
|
|
8
|
+
pypomes_jwt-0.9.1.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
pypomes_jwt/__init__.py,sha256=P7rT6ZVE2BzU3ntYOr83H5iOf5JcCmjDUYakNbrRAP0,1266
|
|
2
|
-
pypomes_jwt/jwt_constants.py,sha256=IQV39AiZKGuU8XxZBgJ-KJZQZ_mmnxyOnRZeuxlqDRk,4045
|
|
3
|
-
pypomes_jwt/jwt_data.py,sha256=FU4zvFr-1ZbClM8ozt9asR2EGZe-xYX3046dxWnGays,22529
|
|
4
|
-
pypomes_jwt/jwt_pomes.py,sha256=XWnP4U2K5FPbP-N5npQboNdMZ-MMLpXTfd8Yf82aFls,17217
|
|
5
|
-
pypomes_jwt-0.8.9.dist-info/METADATA,sha256=orVERU_gL14nmPjxxNMrxyEKy3q4LLBD2vMokmyECSQ,632
|
|
6
|
-
pypomes_jwt-0.8.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
-
pypomes_jwt-0.8.9.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
|
|
8
|
-
pypomes_jwt-0.8.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|