pypomes-jwt 0.8.1__py3-none-any.whl → 0.8.3__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.
- pypomes_jwt/__init__.py +8 -6
- pypomes_jwt/jwt_constants.py +11 -20
- pypomes_jwt/jwt_data.py +139 -86
- pypomes_jwt/jwt_pomes.py +113 -80
- {pypomes_jwt-0.8.1.dist-info → pypomes_jwt-0.8.3.dist-info}/METADATA +2 -1
- pypomes_jwt-0.8.3.dist-info/RECORD +8 -0
- pypomes_jwt-0.8.1.dist-info/RECORD +0 -8
- {pypomes_jwt-0.8.1.dist-info → pypomes_jwt-0.8.3.dist-info}/WHEEL +0 -0
- {pypomes_jwt-0.8.1.dist-info → pypomes_jwt-0.8.3.dist-info}/licenses/LICENSE +0 -0
pypomes_jwt/__init__.py
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
from .jwt_constants import (
|
|
2
2
|
JWT_DB_ENGINE, JWT_DB_HOST, JWT_DB_NAME,
|
|
3
3
|
JWT_DB_PORT, JWT_DB_USER, JWT_DB_PWD,
|
|
4
|
-
JWT_DB_TABLE,
|
|
4
|
+
JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ACCOUNT,
|
|
5
|
+
JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER, JWT_DB_COL_TOKEN,
|
|
5
6
|
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
|
|
6
7
|
JWT_ENCODING_KEY, JWT_DECODING_KEY
|
|
7
8
|
)
|
|
8
9
|
from .jwt_pomes import (
|
|
9
10
|
jwt_needed, jwt_verify_request,
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
jwt_assert_account, jwt_set_account, jwt_remove_account,
|
|
12
|
+
jwt_get_tokens, jwt_get_claims, jwt_validate_token, jwt_revoke_token
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
__all__ = [
|
|
15
16
|
# jwt_constants
|
|
16
17
|
"JWT_DB_ENGINE", "JWT_DB_HOST", "JWT_DB_NAME",
|
|
17
18
|
"JWT_DB_PORT", "JWT_DB_USER", "JWT_DB_PWD",
|
|
18
|
-
"JWT_DB_TABLE", "
|
|
19
|
+
"JWT_DB_TABLE", "JWT_DB_COL_KID", "JWT_DB_COL_ACCOUNT",
|
|
20
|
+
"JWT_DB_COL_ALGORITHM", "JWT_DB_COL_DECODER", "JWT_DB_COL_TOKEN",
|
|
19
21
|
"JWT_ACCESS_MAX_AGE", "JWT_REFRESH_MAX_AGE",
|
|
20
22
|
"JWT_ENCODING_KEY", "JWT_DECODING_KEY",
|
|
21
23
|
# jwt_pomes
|
|
22
24
|
"jwt_needed", "jwt_verify_request",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
+
"jwt_assert_account", "jwt_set_account", "jwt_remove_account",
|
|
26
|
+
"jwt_get_tokens", "jwt_get_claims", "jwt_validate_token", "jwt_revoke_token"
|
|
25
27
|
]
|
|
26
28
|
|
|
27
29
|
from importlib.metadata import version
|
pypomes_jwt/jwt_constants.py
CHANGED
|
@@ -5,7 +5,9 @@ from pypomes_core import (
|
|
|
5
5
|
APP_PREFIX,
|
|
6
6
|
env_get_str, env_get_bytes, env_get_int
|
|
7
7
|
)
|
|
8
|
+
from pypomes_db import DbEngine, db_setup
|
|
8
9
|
from secrets import token_bytes
|
|
10
|
+
from sys import stderr
|
|
9
11
|
from typing import Final
|
|
10
12
|
|
|
11
13
|
# database specs for token persistence
|
|
@@ -18,14 +20,14 @@ JWT_DB_CLIENT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_CLIENT") # fo
|
|
|
18
20
|
JWT_DB_DRIVER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_DRIVER") # for SQLServer, only
|
|
19
21
|
JWT_DB_TABLE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_TABLE")
|
|
20
22
|
JWT_DB_COL_ACCOUNT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_ACCOUNT")
|
|
21
|
-
|
|
23
|
+
JWT_DB_COL_ALGORITHM: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_ALGORITHM")
|
|
24
|
+
JWT_DB_COL_DECODER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_DECODER")
|
|
25
|
+
JWT_DB_COL_KID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_KID")
|
|
22
26
|
JWT_DB_COL_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_TOKEN")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
from sys import stderr
|
|
28
|
-
if db_setup(engine=DbEngine(__db_engine),
|
|
27
|
+
|
|
28
|
+
# define and validate the database engine
|
|
29
|
+
JWT_DB_ENGINE: Final[DbEngine] = DbEngine(env_get_str(key=f"{APP_PREFIX}_JWT_DB_ENGINE"))
|
|
30
|
+
if not db_setup(engine=JWT_DB_ENGINE,
|
|
29
31
|
db_name=JWT_DB_NAME,
|
|
30
32
|
db_user=JWT_DB_USER,
|
|
31
33
|
db_pwd=JWT_DB_PWD,
|
|
@@ -33,22 +35,11 @@ if __db_engine:
|
|
|
33
35
|
db_port=JWT_DB_PORT,
|
|
34
36
|
db_client=JWT_DB_CLIENT,
|
|
35
37
|
db_driver=JWT_DB_DRIVER):
|
|
36
|
-
|
|
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
|
-
# if set to 'None', no further attempt will be made to access the database
|
|
46
|
-
JWT_DB_ENGINE: Final[DbEngine] = DbEngine(__db_engine) if __db_engine else None
|
|
38
|
+
stderr.write("Invalid database parameters\n")
|
|
47
39
|
|
|
48
|
-
# one of HS256, HS512,
|
|
40
|
+
# one of HS256, HS512, RS256, RS512
|
|
49
41
|
JWT_DEFAULT_ALGORITHM: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DEFAULT_ALGORITHM",
|
|
50
42
|
def_value="RS256")
|
|
51
|
-
|
|
52
43
|
# recommended: between 5 min and 1 hour (set to 5 min)
|
|
53
44
|
JWT_ACCESS_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCESS_MAX_AGE",
|
|
54
45
|
def_value=300)
|
pypomes_jwt/jwt_data.py
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import hashlib
|
|
2
1
|
import jwt
|
|
3
2
|
import requests
|
|
4
3
|
import string
|
|
4
|
+
import sys
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from logging import Logger
|
|
7
7
|
from pypomes_core import str_random
|
|
8
|
+
from pypomes_db import db_connect, db_commit, db_update, db_delete
|
|
8
9
|
from requests import Response
|
|
9
10
|
from threading import Lock
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
from .jwt_constants import (
|
|
13
|
-
JWT_DEFAULT_ALGORITHM, JWT_ENCODING_KEY, JWT_DECODING_KEY,
|
|
14
|
-
|
|
14
|
+
JWT_DEFAULT_ALGORITHM, JWT_ACCOUNT_LIMIT, JWT_ENCODING_KEY, JWT_DECODING_KEY,
|
|
15
|
+
JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ACCOUNT,
|
|
16
|
+
JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER, JWT_DB_COL_TOKEN
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
|
|
@@ -149,12 +151,10 @@ class JwtData:
|
|
|
149
151
|
with self.access_lock:
|
|
150
152
|
account_data = self.access_data.pop(account_id, None)
|
|
151
153
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
where_data={JWT_DB_COL_ACCOUNT: account_id},
|
|
157
|
-
logger=logger)
|
|
154
|
+
db_delete(errors=None,
|
|
155
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
156
|
+
where_data={JWT_DB_COL_ACCOUNT: account_id},
|
|
157
|
+
logger=logger)
|
|
158
158
|
if logger:
|
|
159
159
|
if account_data:
|
|
160
160
|
logger.debug(f"Removed JWT data for '{account_id}'")
|
|
@@ -192,8 +192,7 @@ class JwtData:
|
|
|
192
192
|
:raises InvalidIssuerError: 'iss' claim does not match the expected issuer
|
|
193
193
|
:raises InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
194
194
|
:raises MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
195
|
-
:raises RuntimeError: error accessing the
|
|
196
|
-
the remote JWT provider failed to return a token
|
|
195
|
+
:raises RuntimeError: error accessing the token database
|
|
197
196
|
"""
|
|
198
197
|
# initialize the return variable
|
|
199
198
|
result: dict[str, Any] | None = None
|
|
@@ -209,8 +208,6 @@ class JwtData:
|
|
|
209
208
|
current_claims: dict[str, Any] = account_data.get("claims").copy()
|
|
210
209
|
if account_claims:
|
|
211
210
|
current_claims.update(account_claims)
|
|
212
|
-
|
|
213
|
-
# obtain new tokens
|
|
214
211
|
current_claims["jti"] = str_random(size=32,
|
|
215
212
|
chars=string.ascii_letters + string.digits)
|
|
216
213
|
current_claims["sub"] = account_id
|
|
@@ -233,41 +230,52 @@ class JwtData:
|
|
|
233
230
|
timeout=account_data.get("request-timeout"),
|
|
234
231
|
logger=logger)
|
|
235
232
|
if errors:
|
|
236
|
-
raise RuntimeError("
|
|
233
|
+
raise RuntimeError("; ".join(errors))
|
|
237
234
|
else:
|
|
238
235
|
# JWT service is being provided locally
|
|
239
236
|
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
240
237
|
current_claims["iat"] = just_now
|
|
241
|
-
token_header: dict[str, Any] = None \
|
|
242
|
-
if JWT_DEFAULT_ALGORITHM not in ["RSA256", "RSA512"] \
|
|
243
|
-
else {"kid": JWT_DECODING_KEY}
|
|
244
|
-
|
|
245
|
-
# issue the access token first
|
|
246
|
-
current_claims["nat"] = "A"
|
|
247
|
-
current_claims["exp"] = just_now + account_data.get("access-max-age")
|
|
248
|
-
# may raise an exception
|
|
249
|
-
access_token: str = jwt.encode(payload=current_claims,
|
|
250
|
-
key=JWT_ENCODING_KEY,
|
|
251
|
-
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
252
|
-
headers=token_header)
|
|
253
238
|
|
|
254
|
-
#
|
|
239
|
+
# issue a candidate refresh token first, and persist it
|
|
255
240
|
current_claims["exp"] = just_now + account_data.get("refresh-max-age")
|
|
256
|
-
current_claims["nat"] = "R"
|
|
257
241
|
# may raise an exception
|
|
258
242
|
refresh_token: str = jwt.encode(payload=current_claims,
|
|
259
243
|
key=JWT_ENCODING_KEY,
|
|
260
|
-
algorithm=JWT_DEFAULT_ALGORITHM
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
244
|
+
algorithm=JWT_DEFAULT_ALGORITHM)
|
|
245
|
+
# obtain a DB connection (may raise an exception)
|
|
246
|
+
db_conn: Any = db_connect(errors=errors,
|
|
247
|
+
logger=logger)
|
|
248
|
+
# persist the candidate token (may raise an exception)
|
|
249
|
+
token_id: int = _jwt_persist_token(errors=errors,
|
|
250
|
+
account_id=account_id,
|
|
251
|
+
jwt_token=refresh_token,
|
|
252
|
+
db_conn=db_conn,
|
|
253
|
+
logger=logger)
|
|
254
|
+
# issue the definitive refresh token
|
|
255
|
+
refresh_token = jwt.encode(payload=current_claims,
|
|
256
|
+
key=JWT_ENCODING_KEY,
|
|
257
|
+
algorithm=JWT_DEFAULT_ALGORITHM,
|
|
258
|
+
headers={"kid": str(token_id)})
|
|
259
|
+
# persist it
|
|
260
|
+
db_update(errors=errors,
|
|
261
|
+
update_stmt=f"UPDATE {JWT_DB_TABLE}",
|
|
262
|
+
update_data={JWT_DB_COL_TOKEN: refresh_token},
|
|
263
|
+
where_data={JWT_DB_COL_KID: token_id},
|
|
264
|
+
connection=db_conn,
|
|
265
|
+
logger=logger)
|
|
266
|
+
# commit the transaction
|
|
267
|
+
db_commit(errors=errors,
|
|
268
|
+
connection=db_conn,
|
|
269
|
+
logger=logger)
|
|
270
|
+
if errors:
|
|
271
|
+
raise RuntimeError("; ".join(errors))
|
|
270
272
|
|
|
273
|
+
# issue the access token
|
|
274
|
+
current_claims["exp"] = just_now + account_data.get("access-max-age")
|
|
275
|
+
# may raise an exception
|
|
276
|
+
access_token: str = jwt.encode(payload=current_claims,
|
|
277
|
+
key=JWT_ENCODING_KEY,
|
|
278
|
+
algorithm=JWT_DEFAULT_ALGORITHM)
|
|
271
279
|
# return the token data
|
|
272
280
|
result = {
|
|
273
281
|
"access_token": access_token,
|
|
@@ -342,64 +350,109 @@ def _jwt_request_token(errors: list[str],
|
|
|
342
350
|
def _jwt_persist_token(errors: list[str],
|
|
343
351
|
account_id: str,
|
|
344
352
|
jwt_token: str,
|
|
345
|
-
|
|
353
|
+
db_conn: Any = None,
|
|
354
|
+
logger: Logger = None) -> int:
|
|
346
355
|
"""
|
|
347
356
|
Persist the given token, making sure that the account limit is adhered to.
|
|
348
357
|
|
|
358
|
+
The tokens in storage, associated with *account_id*, are examined for their expiration timestamp.
|
|
359
|
+
If a token's expiration timestamp is in the past, it is removed from storage. If the maximum number
|
|
360
|
+
of active tokens for *account_id* has been reached, the oldest active one is alse removed,
|
|
361
|
+
to make room for the new *jwt_token*.
|
|
362
|
+
|
|
363
|
+
If *db_conn* is provided, then all DB operations will be carried out in the scope of a single transaction.
|
|
364
|
+
|
|
349
365
|
:param errors: incidental errors
|
|
350
366
|
:param account_id: the account identification
|
|
351
367
|
:param jwt_token: the JWT token to persist
|
|
368
|
+
:param db_conn: the database connection to use
|
|
352
369
|
:param logger: optional logger
|
|
370
|
+
:return: the storage id of the inserted token
|
|
371
|
+
:raises RuntimeError: error accessing the revocation database
|
|
353
372
|
"""
|
|
354
373
|
from pypomes_db import db_select, db_insert, db_delete
|
|
355
374
|
from .jwt_pomes import jwt_get_claims
|
|
356
375
|
|
|
357
376
|
# retrieve the account's tokens
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
377
|
+
# noinspection PyTypeChecker
|
|
378
|
+
recs: list[tuple[int, str, str, str]] = \
|
|
379
|
+
db_select(errors=errors,
|
|
380
|
+
sel_stmt=f"SELECT {JWT_DB_COL_KID}, {JWT_DB_COL_TOKEN} "
|
|
381
|
+
f"FROM {JWT_DB_TABLE}",
|
|
382
|
+
where_data={JWT_DB_COL_ACCOUNT: account_id},
|
|
383
|
+
connection=db_conn)
|
|
384
|
+
if errors:
|
|
385
|
+
raise RuntimeError("; ".join(errors))
|
|
386
|
+
|
|
387
|
+
if logger:
|
|
388
|
+
logger.debug(msg=f"Read {len(recs)} token from storage for account '{account_id}'")
|
|
389
|
+
# remove the expired tokens
|
|
390
|
+
oldest: int = sys.maxsize
|
|
391
|
+
surplus: int | None = None
|
|
392
|
+
expired: list[int] = []
|
|
393
|
+
for rec in recs:
|
|
394
|
+
token: str = rec[1]
|
|
395
|
+
token_kid: int = rec[0]
|
|
396
|
+
token_claims: dict[str, Any] = jwt_get_claims(errors=errors,
|
|
397
|
+
token=token,
|
|
398
|
+
validate=False,
|
|
399
|
+
logger=logger)
|
|
400
|
+
if errors:
|
|
401
|
+
raise RuntimeError("; ".join(errors))
|
|
402
|
+
|
|
403
|
+
exp: int = token_claims["payload"].get("exp")
|
|
404
|
+
if exp < datetime.now(tz=timezone.utc).timestamp():
|
|
405
|
+
expired.append(token_kid)
|
|
406
|
+
elif exp < oldest:
|
|
407
|
+
oldest = exp
|
|
408
|
+
surplus = token_kid
|
|
409
|
+
|
|
410
|
+
# remove expired tokens from persistence
|
|
411
|
+
# ruff: noqa: SIM102
|
|
412
|
+
if expired:
|
|
413
|
+
db_delete(errors=errors,
|
|
414
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
415
|
+
where_data={JWT_DB_COL_KID: expired},
|
|
416
|
+
connection=db_conn,
|
|
417
|
+
logger=logger)
|
|
418
|
+
if errors:
|
|
419
|
+
raise RuntimeError("; ".join(errors))
|
|
420
|
+
if logger:
|
|
421
|
+
logger.debug(msg=f"{len(expired)} tokens of account "
|
|
422
|
+
f"'{account_id}' removed from storage")
|
|
423
|
+
|
|
424
|
+
if 0 < JWT_ACCOUNT_LIMIT <= len(recs) - len(expired):
|
|
425
|
+
# delete the oldest persisted token to make way for the new one
|
|
426
|
+
db_delete(errors=errors,
|
|
427
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
428
|
+
where_data={JWT_DB_COL_KID: surplus},
|
|
429
|
+
connection=db_conn,
|
|
430
|
+
logger=logger)
|
|
431
|
+
if errors:
|
|
432
|
+
raise RuntimeError("; ".join(errors))
|
|
362
433
|
if logger:
|
|
363
|
-
logger.debug(msg=
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
},
|
|
389
|
-
logger=logger) is not None:
|
|
390
|
-
if logger:
|
|
391
|
-
logger.debug(msg=f"{len(expired)} tokens removed from storage")
|
|
392
|
-
if 0 < JWT_ACCOUNT_LIMIT <= len(recs) - len(expired):
|
|
393
|
-
errors.append("Maximum number of active sessions "
|
|
394
|
-
f"({JWT_ACCOUNT_LIMIT}) exceeded for account '{account_id}'")
|
|
395
|
-
# persist token
|
|
396
|
-
if not errors:
|
|
397
|
-
# ruff: noqa: S324
|
|
398
|
-
hasher = hashlib.new(name="md5",
|
|
399
|
-
data=jwt_token.encode())
|
|
400
|
-
token_hash: str = hasher.digest().hex()
|
|
401
|
-
db_insert(errors=errors,
|
|
402
|
-
insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
|
|
403
|
-
insert_data={JWT_DB_COL_ACCOUNT: account_id,
|
|
404
|
-
JWT_DB_COL_HASH: token_hash,
|
|
405
|
-
JWT_DB_COL_TOKEN: jwt_token})
|
|
434
|
+
logger.debug(msg="Oldest active token of account "
|
|
435
|
+
f"'{account_id}' removed from storage")
|
|
436
|
+
# persist token
|
|
437
|
+
db_insert(errors=errors,
|
|
438
|
+
insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
|
|
439
|
+
insert_data={JWT_DB_COL_ACCOUNT: account_id,
|
|
440
|
+
JWT_DB_COL_TOKEN: jwt_token,
|
|
441
|
+
JWT_DB_COL_ALGORITHM: JWT_DEFAULT_ALGORITHM,
|
|
442
|
+
JWT_DB_COL_DECODER: JWT_DECODING_KEY.hex()},
|
|
443
|
+
connection=db_conn,
|
|
444
|
+
logger=logger)
|
|
445
|
+
if errors:
|
|
446
|
+
raise RuntimeError("; ".join(errors))
|
|
447
|
+
|
|
448
|
+
# obtain the token's storage id
|
|
449
|
+
reply: list[tuple[int]] = db_select(errors=errors,
|
|
450
|
+
sel_stmt=f"SELECT {JWT_DB_COL_KID} "
|
|
451
|
+
f"FROM {JWT_DB_TABLE}",
|
|
452
|
+
where_data={JWT_DB_COL_TOKEN: jwt_token},
|
|
453
|
+
connection=db_conn,
|
|
454
|
+
logger=logger)
|
|
455
|
+
if errors:
|
|
456
|
+
raise RuntimeError("; ".join(errors))
|
|
457
|
+
|
|
458
|
+
return reply[0][0]
|
pypomes_jwt/jwt_pomes.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import hashlib
|
|
2
1
|
import jwt
|
|
3
2
|
from flask import Request, Response, request
|
|
4
3
|
from logging import Logger
|
|
4
|
+
from pypomes_db import db_select, db_delete
|
|
5
5
|
from typing import Any, Literal
|
|
6
6
|
|
|
7
|
+
from . import JWT_DB_COL_ACCOUNT
|
|
7
8
|
from .jwt_constants import (
|
|
8
9
|
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
|
|
9
10
|
JWT_DEFAULT_ALGORITHM, JWT_DECODING_KEY,
|
|
10
|
-
|
|
11
|
-
JWT_DB_COL_ACCOUNT, JWT_DB_COL_HASH, JWT_DB_COL_TOKEN
|
|
11
|
+
JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER
|
|
12
12
|
)
|
|
13
13
|
from .jwt_data import JwtData
|
|
14
14
|
|
|
@@ -74,7 +74,6 @@ def jwt_verify_request(request: Request,
|
|
|
74
74
|
logger.error(msg=err_msg)
|
|
75
75
|
result = Response(response="Authorization failed",
|
|
76
76
|
status=401)
|
|
77
|
-
|
|
78
77
|
return result
|
|
79
78
|
|
|
80
79
|
|
|
@@ -158,7 +157,8 @@ def jwt_remove_account(account_id: str,
|
|
|
158
157
|
def jwt_validate_token(errors: list[str] | None,
|
|
159
158
|
token: str,
|
|
160
159
|
nature: Literal["A", "R"] = None,
|
|
161
|
-
|
|
160
|
+
account_id: str = None,
|
|
161
|
+
logger: Logger = None) -> dict[str, Any] | None:
|
|
162
162
|
"""
|
|
163
163
|
Verify if *token* ia a valid JWT token.
|
|
164
164
|
|
|
@@ -167,37 +167,87 @@ def jwt_validate_token(errors: list[str] | None,
|
|
|
167
167
|
:param errors: incidental error messages
|
|
168
168
|
:param token: the token to be validated
|
|
169
169
|
:param nature: optionally validate the token's nature ("A": access token, "R": refresh token)
|
|
170
|
+
:param account_id: optionally, validate the token's account owner
|
|
170
171
|
:param logger: optional logger
|
|
171
|
-
:return:
|
|
172
|
+
:return: The token's claims (header and payload) if if is valid, *None* otherwise
|
|
172
173
|
"""
|
|
174
|
+
# initialize the return variable
|
|
175
|
+
result: dict[str, Any] | None = None
|
|
173
176
|
if logger:
|
|
174
177
|
logger.debug(msg=f"Validate JWT token '{token}'")
|
|
175
178
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
# InvalidSignatureError: signature does not match the one provided as part of the token
|
|
183
|
-
claims: dict[str, Any] = jwt.decode(jwt=token,
|
|
184
|
-
key=JWT_DECODING_KEY,
|
|
185
|
-
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
186
|
-
if nature and nature != claims.get("nat"):
|
|
187
|
-
nat: str = "an access" if nature == "A" else "a refresh"
|
|
188
|
-
err_msg = f"Token is not {nat} token"
|
|
189
|
-
except Exception as e:
|
|
190
|
-
err_msg = str(e)
|
|
179
|
+
# extract needed data from token header
|
|
180
|
+
token_header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
|
|
181
|
+
token_kid: int = int(token_header.get("kid") or 0)
|
|
182
|
+
token_alg: str | None = None
|
|
183
|
+
token_decoder: bytes | None = None
|
|
184
|
+
op_errors: list[str] = []
|
|
191
185
|
|
|
192
|
-
|
|
186
|
+
# retrieve token data from database
|
|
187
|
+
if (nature == "R" and not token_kid) or (nature == "A" and token_kid):
|
|
188
|
+
nat: str = "an access" if nature == "A" else "a refresh"
|
|
189
|
+
op_errors.append(f"Token is not {nat} token")
|
|
190
|
+
elif token_kid:
|
|
191
|
+
where_data: dict[str, str] = {JWT_DB_COL_KID: token_kid}
|
|
192
|
+
if account_id:
|
|
193
|
+
where_data[JWT_DB_COL_ACCOUNT] = account_id
|
|
194
|
+
recs: list[tuple[str]] = db_select(errors=op_errors,
|
|
195
|
+
sel_stmt=f"SELECT {JWT_DB_COL_ALGORITHM}, {JWT_DB_COL_DECODER} "
|
|
196
|
+
f"FROM {JWT_DB_TABLE}",
|
|
197
|
+
where_data=where_data,
|
|
198
|
+
logger=logger)
|
|
199
|
+
if recs:
|
|
200
|
+
token_alg = recs[0][0]
|
|
201
|
+
token_decoder = bytes.fromhex(recs[0][1])
|
|
202
|
+
else:
|
|
203
|
+
op_errors.append("Invalid token")
|
|
204
|
+
else:
|
|
205
|
+
token_alg = JWT_DEFAULT_ALGORITHM
|
|
206
|
+
token_decoder = JWT_DECODING_KEY
|
|
207
|
+
|
|
208
|
+
# validate the token
|
|
209
|
+
if not op_errors:
|
|
210
|
+
try:
|
|
211
|
+
# raises:
|
|
212
|
+
# InvalidTokenError: token is invalid
|
|
213
|
+
# InvalidKeyError: authentication key is not in the proper format
|
|
214
|
+
# ExpiredSignatureError: token and refresh period have expired
|
|
215
|
+
# InvalidSignatureError: signature does not match the one provided as part of the token
|
|
216
|
+
# ImmatureSignatureError: 'nbf' or 'iat' claim represents a timestamp in the future
|
|
217
|
+
# InvalidAudienceError: 'aud' claim does not match one of the expected audience
|
|
218
|
+
# InvalidAlgorithmError: the specified algorithm is not recognized
|
|
219
|
+
# InvalidIssuerError: 'iss' claim does not match the expected issuer
|
|
220
|
+
# InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
221
|
+
# MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
222
|
+
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
223
|
+
options={
|
|
224
|
+
"verify_signature": True,
|
|
225
|
+
"verify_exp": True,
|
|
226
|
+
"verify_nbf": True
|
|
227
|
+
},
|
|
228
|
+
key=token_decoder,
|
|
229
|
+
require=["exp", "nbf"],
|
|
230
|
+
algorithms=token_alg)
|
|
231
|
+
if account_id and payload.get("sub") != account_id:
|
|
232
|
+
op_errors.append("Token does not belong to account")
|
|
233
|
+
else:
|
|
234
|
+
result = {
|
|
235
|
+
"header": token_header,
|
|
236
|
+
"payload": payload
|
|
237
|
+
}
|
|
238
|
+
except Exception as e:
|
|
239
|
+
op_errors.append(str(e))
|
|
240
|
+
|
|
241
|
+
if op_errors:
|
|
242
|
+
err_msg: str = "; ".join(op_errors)
|
|
193
243
|
if logger:
|
|
194
244
|
logger.error(msg=err_msg)
|
|
195
245
|
if isinstance(errors, list):
|
|
196
|
-
errors.
|
|
246
|
+
errors.extend(op_errors)
|
|
197
247
|
elif logger:
|
|
198
248
|
logger.debug(msg=f"Token '{token}' is valid")
|
|
199
249
|
|
|
200
|
-
return
|
|
250
|
+
return result
|
|
201
251
|
|
|
202
252
|
|
|
203
253
|
def jwt_revoke_token(errors: list[str] | None,
|
|
@@ -222,25 +272,20 @@ def jwt_revoke_token(errors: list[str] | None,
|
|
|
222
272
|
logger.debug(msg=f"Revoking refresh token of '{account_id}'")
|
|
223
273
|
|
|
224
274
|
op_errors: list[str] = []
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
elif not op_errors:
|
|
240
|
-
op_errors.append("Token was not found")
|
|
241
|
-
else:
|
|
242
|
-
op_errors.append("Database access for token revocation has not been specified")
|
|
243
|
-
|
|
275
|
+
token_claims: dict[str, Any] = jwt_validate_token(errors=op_errors,
|
|
276
|
+
token=refresh_token,
|
|
277
|
+
nature="R",
|
|
278
|
+
account_id=account_id,
|
|
279
|
+
logger=logger)
|
|
280
|
+
if not op_errors:
|
|
281
|
+
token_kid: int = int(token_claims["header"].get("kid") or 0)
|
|
282
|
+
db_delete(errors=op_errors,
|
|
283
|
+
delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
|
|
284
|
+
where_data={
|
|
285
|
+
JWT_DB_COL_KID: token_kid,
|
|
286
|
+
JWT_DB_COL_ACCOUNT: account_id
|
|
287
|
+
},
|
|
288
|
+
logger=logger)
|
|
244
289
|
if op_errors:
|
|
245
290
|
if logger:
|
|
246
291
|
logger.error(msg="; ".join(op_errors))
|
|
@@ -273,7 +318,7 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
273
318
|
|
|
274
319
|
:param errors: incidental error messages
|
|
275
320
|
:param account_id: the account identification
|
|
276
|
-
:param account_claims: if provided, may supercede registered
|
|
321
|
+
:param account_claims: if provided, may supercede registered claims
|
|
277
322
|
:param refresh_token: if provided, defines a token refresh operation
|
|
278
323
|
:param logger: optional logger
|
|
279
324
|
:return: the JWT token data, or *None* if error
|
|
@@ -286,29 +331,24 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
286
331
|
op_errors: list[str] = []
|
|
287
332
|
if refresh_token:
|
|
288
333
|
# verify whether this refresh token is legitimate
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
account_claims = jwt_get_claims(errors=op_errors,
|
|
301
|
-
token=refresh_token)
|
|
302
|
-
if not op_errors and account_claims.get("nat") != "R":
|
|
303
|
-
op_errors.append("Invalid parameters")
|
|
304
|
-
|
|
334
|
+
account_claims = (jwt_validate_token(errors=op_errors,
|
|
335
|
+
token=refresh_token,
|
|
336
|
+
nature="R",
|
|
337
|
+
account_id=account_id,
|
|
338
|
+
logger=logger) or {}).get("payload")
|
|
339
|
+
if account_claims:
|
|
340
|
+
account_claims.pop("iat", None)
|
|
341
|
+
account_claims.pop("jti", None)
|
|
342
|
+
account_claims.pop("iss", None)
|
|
343
|
+
account_claims.pop("exp", None)
|
|
344
|
+
account_claims.pop("nbt", None)
|
|
305
345
|
if not op_errors:
|
|
306
346
|
try:
|
|
307
347
|
result = __jwt_data.issue_tokens(account_id=account_id,
|
|
308
348
|
account_claims=account_claims,
|
|
309
349
|
logger=logger)
|
|
310
350
|
if logger:
|
|
311
|
-
logger.debug(msg=f"
|
|
351
|
+
logger.debug(msg=f"Token data is '{result}'")
|
|
312
352
|
except Exception as e:
|
|
313
353
|
# token issuing failed
|
|
314
354
|
op_errors.append(str(e))
|
|
@@ -324,8 +364,8 @@ def jwt_get_tokens(errors: list[str] | None,
|
|
|
324
364
|
|
|
325
365
|
def jwt_get_claims(errors: list[str] | None,
|
|
326
366
|
token: str,
|
|
327
|
-
validate: bool =
|
|
328
|
-
logger: Logger = None) -> dict[str, Any]:
|
|
367
|
+
validate: bool = False,
|
|
368
|
+
logger: Logger = None) -> dict[str, Any] | None:
|
|
329
369
|
"""
|
|
330
370
|
Obtain and return the claims set of a JWT *token*.
|
|
331
371
|
|
|
@@ -361,7 +401,7 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
361
401
|
|
|
362
402
|
:param errors: incidental error messages
|
|
363
403
|
:param token: the token to be inspected for claims
|
|
364
|
-
:param validate: If *True*, verifies the token's data
|
|
404
|
+
:param validate: If *True*, verifies the token's data (defaults to *False*)
|
|
365
405
|
:param logger: optional logger
|
|
366
406
|
:return: the token's claimset, or *None* if error
|
|
367
407
|
"""
|
|
@@ -372,26 +412,19 @@ def jwt_get_claims(errors: list[str] | None,
|
|
|
372
412
|
logger.debug(msg=f"Retrieve claims for token '{token}'")
|
|
373
413
|
|
|
374
414
|
try:
|
|
375
|
-
# retrieve the token's
|
|
415
|
+
# retrieve the token's claims
|
|
376
416
|
if validate:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
"verify_exp": True,
|
|
381
|
-
"verify_nbf": True
|
|
382
|
-
},
|
|
383
|
-
key=JWT_DECODING_KEY,
|
|
384
|
-
require=["exp", "nbf"],
|
|
385
|
-
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
417
|
+
result = jwt_validate_token(errors=errors,
|
|
418
|
+
token=token,
|
|
419
|
+
logger=logger)
|
|
386
420
|
else:
|
|
421
|
+
header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
|
|
387
422
|
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
388
423
|
options={"verify_signature": False})
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
"payload": payload
|
|
394
|
-
}
|
|
424
|
+
result = {
|
|
425
|
+
"header": header,
|
|
426
|
+
"payload": payload
|
|
427
|
+
}
|
|
395
428
|
except Exception as e:
|
|
396
429
|
if logger:
|
|
397
430
|
logger.error(msg=str(e))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_jwt
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
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,3 +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.3
|
|
16
|
+
Requires-Dist: pypomes-db>=1.9.5
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pypomes_jwt/__init__.py,sha256=P7rT6ZVE2BzU3ntYOr83H5iOf5JcCmjDUYakNbrRAP0,1266
|
|
2
|
+
pypomes_jwt/jwt_constants.py,sha256=FA50jKQ3D09MxXkUpVkXW5IQqm_UX6qm3bU5gHvkU-4,3980
|
|
3
|
+
pypomes_jwt/jwt_data.py,sha256=4WT19eHowrMyXJIRt3nLv8FLxEorgP2k-fgKgYY0Vgk,21534
|
|
4
|
+
pypomes_jwt/jwt_pomes.py,sha256=m-seMYrQLgTrdxR7bH-RMdlY7Jc9QtmAKmkdyEAhQGY,17156
|
|
5
|
+
pypomes_jwt-0.8.3.dist-info/METADATA,sha256=kagRg42MtBTc2zewA2douDPWBYBmeOCsSwSuciJZnNA,632
|
|
6
|
+
pypomes_jwt-0.8.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
pypomes_jwt-0.8.3.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
|
|
8
|
+
pypomes_jwt-0.8.3.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
pypomes_jwt/__init__.py,sha256=06WdwiP2m5jtrFjpPSacg4fRd2Dh6gVo93xJhmu73J4,1134
|
|
2
|
-
pypomes_jwt/jwt_constants.py,sha256=EjdrTP5AptGoOdI0gzsxexmM4lrgm2r0KHX-DyyGhFc,4330
|
|
3
|
-
pypomes_jwt/jwt_data.py,sha256=q4KUVOuLXHA9tVIfuVEPo8uZPulElWM04wqtGVxcV-0,19239
|
|
4
|
-
pypomes_jwt/jwt_pomes.py,sha256=lACMvNHRVpGgOGmQJ67zbURnR6p4kcxU4UomoZYahto,15246
|
|
5
|
-
pypomes_jwt-0.8.1.dist-info/METADATA,sha256=g4cWSIxewY90pfkQ2gLJlLdE_LqHeX2E6mU2CfX0eak,599
|
|
6
|
-
pypomes_jwt-0.8.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
-
pypomes_jwt-0.8.1.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
|
|
8
|
-
pypomes_jwt-0.8.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|