pypomes-jwt 0.8.2__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 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, JWT_DB_COL_ACCOUNT, JWT_DB_COL_TOKEN,
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
- jwt_get_tokens, jwt_get_claims, jwt_validate_token,
11
- jwt_assert_account, jwt_set_account, jwt_remove_account, jwt_revoke_token
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", "JWT_DB_COL_ACCOUNT", "JWT_DB_COL_TOKEN",
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
- "jwt_get_tokens", "jwt_get_claims", "jwt_validate_token",
24
- "jwt_assert_account", "jwt_set_account", "jwt_remove_account", "jwt_revoke_token"
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
@@ -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
- JWT_DB_COL_HASH: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_HASH")
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
- # define the database engine
24
- __db_engine: str | None = env_get_str(key=f"{APP_PREFIX}_JWT_DB_ENGINE")
25
- if __db_engine:
26
- from pypomes_db import DbEngine, db_setup, db_assert_access, db_delete
27
- from sys import stderr
28
- if db_setup(engine=DbEngine(__db_engine),
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
- __errors: list[str] = []
37
- if not db_assert_access(errors=__errors) or \
38
- db_delete(errors=__errors,
39
- delete_stmt=f"DELETE FROM {JWT_DB_TABLE}") is None:
40
- stderr.write(f"{'; '.join(__errors)}\n")
41
- __db_engine = None
42
- else:
43
- stderr.write("Invalid database parameters\n")
44
- __db_engine = None
45
- # 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, RSA256, RSA512
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, JWT_ACCOUNT_LIMIT,
14
- JWT_DB_ENGINE, JWT_DB_TABLE, JWT_DB_COL_ACCOUNT, JWT_DB_COL_HASH, JWT_DB_COL_TOKEN
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
- if account_data and JWT_DB_ENGINE:
153
- from pypomes_db import db_delete
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)
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 revocation database, or
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(" - ".join(errors))
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 ["RS256", "RS512"] \
243
- else {"kid": JWT_DECODING_KEY.hex()}
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
- # then issue the refresh token
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
- headers=token_header)
262
- if JWT_DB_ENGINE:
263
- # persist the refresh token
264
- _jwt_persist_token(errors=errors,
265
- account_id=account_id,
266
- jwt_token=refresh_token,
267
- logger=logger)
268
- if errors:
269
- raise RuntimeError("; ".join(errors))
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
- logger: Logger = None) -> None:
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
- recs: list[tuple[str]] = db_select(errors=errors,
359
- sel_stmt=f"SELECT {JWT_DB_COL_HASH}, {JWT_DB_COL_TOKEN} FROM {JWT_DB_TABLE} ",
360
- where_data={JWT_DB_COL_ACCOUNT: account_id})
361
- if not errors:
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=f"Read {len(recs)} token from storage for account '{account_id}'")
364
- # remove the expired tokens
365
- expired: list[str] = []
366
- for rec in recs:
367
- token: str = rec[1]
368
- token_hash: str = rec[0]
369
- token_claims: dict[str, Any] = jwt_get_claims(errors=errors,
370
- token=token,
371
- validate=False,
372
- logger=logger)
373
- if errors:
374
- break
375
- exp: int = token_claims["payload"]["exp"]
376
- if exp < datetime.now(tz=timezone.utc).timestamp():
377
- expired.append(token_hash)
378
-
379
- if not errors:
380
- # remove expired tokens from persistence
381
- # ruff: noqa: SIM102
382
- if expired:
383
- if db_delete(errors=errors,
384
- delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
385
- where_data={
386
- JWT_DB_COL_ACCOUNT: account_id,
387
- JWT_DB_COL_HASH: expired
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,13 +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
- JWT_DB_ENGINE, JWT_DB_TABLE, JWT_DB_COL_HASH
11
+ JWT_DB_TABLE, JWT_DB_COL_KID, JWT_DB_COL_ALGORITHM, JWT_DB_COL_DECODER
11
12
  )
12
13
  from .jwt_data import JwtData
13
14
 
@@ -73,7 +74,6 @@ def jwt_verify_request(request: Request,
73
74
  logger.error(msg=err_msg)
74
75
  result = Response(response="Authorization failed",
75
76
  status=401)
76
-
77
77
  return result
78
78
 
79
79
 
@@ -157,7 +157,8 @@ def jwt_remove_account(account_id: str,
157
157
  def jwt_validate_token(errors: list[str] | None,
158
158
  token: str,
159
159
  nature: Literal["A", "R"] = None,
160
- logger: Logger = None) -> bool:
160
+ account_id: str = None,
161
+ logger: Logger = None) -> dict[str, Any] | None:
161
162
  """
162
163
  Verify if *token* ia a valid JWT token.
163
164
 
@@ -166,48 +167,87 @@ def jwt_validate_token(errors: list[str] | None,
166
167
  :param errors: incidental error messages
167
168
  :param token: the token to be validated
168
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
169
171
  :param logger: optional logger
170
- :return: *True* if token is valid, *False* otherwise
172
+ :return: The token's claims (header and payload) if if is valid, *None* otherwise
171
173
  """
174
+ # initialize the return variable
175
+ result: dict[str, Any] | None = None
172
176
  if logger:
173
177
  logger.debug(msg=f"Validate JWT token '{token}'")
174
178
 
175
- err_msg: str | None = None
176
- try:
177
- # raises:
178
- # InvalidTokenError: token is invalid
179
- # InvalidKeyError: authentication key is not in the proper format
180
- # ExpiredSignatureError: token and refresh period have expired
181
- # InvalidSignatureError: signature does not match the one provided as part of the token
182
- claims: dict[str, Any] = jwt.decode(jwt=token,
183
- key=JWT_DECODING_KEY,
184
- algorithms=[JWT_DEFAULT_ALGORITHM])
185
- if nature and nature != claims.get("nat"):
186
- nat: str = "an access" if nature == "A" else "a refresh"
187
- err_msg = f"Token is not {nat} token"
188
- elif JWT_DB_ENGINE and claims.get("nat") == "R":
189
- from pypomes_db import db_exists
190
- # ruff: noqa: S324
191
- hasher = hashlib.new(name="md5",
192
- data=token.encode())
193
- token_hash: str = hasher.digest().hex()
194
- if not db_exists(errors=errors,
195
- table=JWT_DB_TABLE,
196
- where_data={JWT_DB_COL_HASH: token_hash},
197
- logger=logger):
198
- err_msg = "Token is not valid"
199
- except Exception as e:
200
- 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] = []
201
185
 
202
- if err_msg:
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)
203
243
  if logger:
204
244
  logger.error(msg=err_msg)
205
245
  if isinstance(errors, list):
206
- errors.append(err_msg)
246
+ errors.extend(op_errors)
207
247
  elif logger:
208
248
  logger.debug(msg=f"Token '{token}' is valid")
209
249
 
210
- return err_msg is None
250
+ return result
211
251
 
212
252
 
213
253
  def jwt_revoke_token(errors: list[str] | None,
@@ -232,25 +272,20 @@ def jwt_revoke_token(errors: list[str] | None,
232
272
  logger.debug(msg=f"Revoking refresh token of '{account_id}'")
233
273
 
234
274
  op_errors: list[str] = []
235
- if JWT_DB_ENGINE:
236
- from pypomes_db import db_exists, db_delete
237
- # ruff: noqa: S324
238
- hasher = hashlib.new(name="md5",
239
- data=refresh_token.encode())
240
- token_hash: str = hasher.digest().hex()
241
- if db_exists(errors=op_errors,
242
- table=JWT_DB_TABLE,
243
- where_data={JWT_DB_COL_HASH: token_hash},
244
- logger=logger):
245
- db_delete(errors=errors,
246
- delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
247
- where_data={JWT_DB_COL_HASH: token_hash},
248
- logger=logger)
249
- elif not op_errors:
250
- op_errors.append("Token was not found")
251
- else:
252
- op_errors.append("Database access for token revocation has not been specified")
253
-
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)
254
289
  if op_errors:
255
290
  if logger:
256
291
  logger.error(msg="; ".join(op_errors))
@@ -283,7 +318,7 @@ def jwt_get_tokens(errors: list[str] | None,
283
318
 
284
319
  :param errors: incidental error messages
285
320
  :param account_id: the account identification
286
- :param account_claims: if provided, may supercede registered custom claims
321
+ :param account_claims: if provided, may supercede registered claims
287
322
  :param refresh_token: if provided, defines a token refresh operation
288
323
  :param logger: optional logger
289
324
  :return: the JWT token data, or *None* if error
@@ -296,31 +331,24 @@ def jwt_get_tokens(errors: list[str] | None,
296
331
  op_errors: list[str] = []
297
332
  if refresh_token:
298
333
  # verify whether this refresh token is legitimate
299
- if JWT_DB_ENGINE:
300
- from pypomes_db import db_exists
301
- # ruff: noqa: S324
302
- hasher = hashlib.new(name="md5",
303
- data=refresh_token.encode())
304
- token_hash: str = hasher.digest().hex()
305
- if db_exists(errors=op_errors,
306
- table=JWT_DB_TABLE,
307
- where_data={JWT_DB_COL_HASH: token_hash},
308
- logger=logger) is False:
309
- op_errors.append("Invalid refresh token")
310
-
311
- if not op_errors:
312
- account_claims = jwt_get_claims(errors=op_errors,
313
- token=refresh_token)
314
- if not op_errors and (account_claims.get("payload") or {}).get("nat") != "R":
315
- op_errors.append("Invalid parameters")
316
-
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)
317
345
  if not op_errors:
318
346
  try:
319
347
  result = __jwt_data.issue_tokens(account_id=account_id,
320
348
  account_claims=account_claims,
321
349
  logger=logger)
322
350
  if logger:
323
- logger.debug(msg=f"Data is '{result}'")
351
+ logger.debug(msg=f"Token data is '{result}'")
324
352
  except Exception as e:
325
353
  # token issuing failed
326
354
  op_errors.append(str(e))
@@ -336,8 +364,8 @@ def jwt_get_tokens(errors: list[str] | None,
336
364
 
337
365
  def jwt_get_claims(errors: list[str] | None,
338
366
  token: str,
339
- validate: bool = True,
340
- logger: Logger = None) -> dict[str, Any]:
367
+ validate: bool = False,
368
+ logger: Logger = None) -> dict[str, Any] | None:
341
369
  """
342
370
  Obtain and return the claims set of a JWT *token*.
343
371
 
@@ -373,7 +401,7 @@ def jwt_get_claims(errors: list[str] | None,
373
401
 
374
402
  :param errors: incidental error messages
375
403
  :param token: the token to be inspected for claims
376
- :param validate: If *True*, verifies the token's data
404
+ :param validate: If *True*, verifies the token's data (defaults to *False*)
377
405
  :param logger: optional logger
378
406
  :return: the token's claimset, or *None* if error
379
407
  """
@@ -384,26 +412,19 @@ def jwt_get_claims(errors: list[str] | None,
384
412
  logger.debug(msg=f"Retrieve claims for token '{token}'")
385
413
 
386
414
  try:
387
- # retrieve the token's payload
415
+ # retrieve the token's claims
388
416
  if validate:
389
- payload: dict[str, Any] = jwt.decode(jwt=token,
390
- options={
391
- "verify_signature": True,
392
- "verify_exp": True,
393
- "verify_nbf": True
394
- },
395
- key=JWT_DECODING_KEY,
396
- require=["exp", "nbf"],
397
- algorithms=[JWT_DEFAULT_ALGORITHM])
417
+ result = jwt_validate_token(errors=errors,
418
+ token=token,
419
+ logger=logger)
398
420
  else:
421
+ header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
399
422
  payload: dict[str, Any] = jwt.decode(jwt=token,
400
423
  options={"verify_signature": False})
401
- # retrieve the token's header
402
- header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
403
- result = {
404
- "header": header,
405
- "payload": payload
406
- }
424
+ result = {
425
+ "header": header,
426
+ "payload": payload
427
+ }
407
428
  except Exception as e:
408
429
  if logger:
409
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.2
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=d11IsRLKF7_3RTfm5ju-U--eCHJemD50OzQBOzFNtYQ,19243
4
- pypomes_jwt/jwt_pomes.py,sha256=hsWrlq_9OqcScS1fPKFl5yxxjicj_AAE2Z5NfKicDkw,15686
5
- pypomes_jwt-0.8.2.dist-info/METADATA,sha256=gHPs2FSSALkn4gsXnCXnbNBIjDYt7a4QxMY11NYBvb8,599
6
- pypomes_jwt-0.8.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
- pypomes_jwt-0.8.2.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
8
- pypomes_jwt-0.8.2.dist-info/RECORD,,