pypomes-jwt 0.7.6__py3-none-any.whl → 0.7.9__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 CHANGED
@@ -8,7 +8,7 @@ from .jwt_constants import (
8
8
  from .jwt_pomes import (
9
9
  jwt_needed, jwt_verify_request,
10
10
  jwt_get_tokens, jwt_get_claims, jwt_validate_token,
11
- jwt_assert_access, jwt_set_access, jwt_remove_access, jwt_revoke_tokens
11
+ jwt_assert_access, jwt_set_access, jwt_remove_account, jwt_revoke_token
12
12
  )
13
13
 
14
14
  __all__ = [
@@ -21,7 +21,7 @@ __all__ = [
21
21
  # jwt_pomes
22
22
  "jwt_needed", "jwt_verify_request",
23
23
  "jwt_get_tokens", "jwt_get_claims", "jwt_validate_token",
24
- "jwt_assert_access", "jwt_set_access", "jwt_remove_access", "jwt_revoke_tokens"
24
+ "jwt_assert_access", "jwt_set_access", "jwt_remove_account", "jwt_revoke_token"
25
25
  ]
26
26
 
27
27
  from importlib.metadata import version
@@ -3,7 +3,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa
3
3
  from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
4
4
  from pypomes_core import (
5
5
  APP_PREFIX,
6
- env_get_str, env_get_bytes, env_get_int, env_get_bool
6
+ env_get_str, env_get_bytes, env_get_int
7
7
  )
8
8
  from secrets import token_bytes
9
9
  from typing import Final
@@ -18,6 +18,7 @@ JWT_DB_CLIENT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_CLIENT") # fo
18
18
  JWT_DB_DRIVER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_DRIVER") # for SQLServer, only
19
19
  JWT_DB_TABLE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_TABLE")
20
20
  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")
21
22
  JWT_DB_COL_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_COL_TOKEN")
22
23
  # define the database engine
23
24
  __db_engine: str | None = env_get_str(key=f"{APP_PREFIX}_JWT_DB_ENGINE")
@@ -53,8 +54,7 @@ JWT_ACCESS_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCESS_MAX_A
53
54
  # recommended: at least 2 hours (set to 24 hours)
54
55
  JWT_REFRESH_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_REFRESH_MAX_AGE",
55
56
  def_value=86400)
56
- JWT_ROTATE_TOKENS: Final[bool] = env_get_bool(key=f"{APP_PREFIX}_JWT_ROTATE_TOKENS",
57
- def_value=True)
57
+ JWT_ACCOUNT_LIMIT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCOUNT_LIMIT")
58
58
 
59
59
  # recommended: allow the encode and decode keys to be generated anew when app starts
60
60
  __encoding_key: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_ENCODE_KEY")
pypomes_jwt/jwt_data.py CHANGED
@@ -1,3 +1,4 @@
1
+ import hashlib
1
2
  import jwt
2
3
  import requests
3
4
  import string
@@ -9,8 +10,8 @@ from threading import Lock
9
10
  from typing import Any
10
11
 
11
12
  from .jwt_constants import (
12
- JWT_DEFAULT_ALGORITHM, JWT_ENCODING_KEY, JWT_ROTATE_TOKENS,
13
- JWT_DB_ENGINE, JWT_DB_TABLE, JWT_DB_COL_ACCOUNT, JWT_DB_COL_TOKEN
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
15
  )
15
16
 
16
17
 
@@ -19,30 +20,30 @@ class JwtData:
19
20
  Shared JWT data for security token access.
20
21
 
21
22
  Instance variables:
22
- - access_lock: lock for safe multi-threading access
23
- - access_data: dictionary holding the JWT token data, organized by account id:
24
- {
25
- <account-id>: {
26
- "reference-url": # the reference URL
27
- "remote-provider": <bool>, # whether the JWT provider is a remote server
28
- "request-timeout": <int>, # in seconds - defaults to no timeout
29
- "access-max-age": <int>, # in seconds - defaults to JWT_ACCESS_MAX_AGE
30
- "refresh-max-age": <int>, # in seconds - defaults to JWT_REFRESH_MAX_AGE
31
- "grace-interval": <int> # time to wait for token to be valid, in seconds
32
- "token-audience": <string> # the audience the token is intended for
33
- "token_nonce": <string> # value used to associate a client session with a token
34
- "claims": {
35
- "birthdate": <string>, # subject's birth date
36
- "email": <string>, # subject's email
37
- "gender": <string>, # subject's gender
38
- "name": <string>, # subject's name
39
- "roles": <List[str]>, # subject roles
40
- "nonce": <string>, # value used to associate a Client session with a token
41
- ...
42
- }
43
- },
44
- ...
45
- }
23
+ - access_lock: lock for safe multi-threading access
24
+ - access_data: dictionary holding the JWT token data, organized by account id:
25
+ {
26
+ <account-id>: {
27
+ "reference-url": # the reference URL
28
+ "remote-provider": <bool>, # whether the JWT provider is a remote server
29
+ "request-timeout": <int>, # in seconds - defaults to no timeout
30
+ "access-max-age": <int>, # in seconds - defaults to JWT_ACCESS_MAX_AGE
31
+ "refresh-max-age": <int>, # in seconds - defaults to JWT_REFRESH_MAX_AGE
32
+ "grace-interval": <int> # time to wait for token to be valid, in seconds
33
+ "token-audience": <string> # the audience the token is intended for
34
+ "token_nonce": <string> # value used to associate a client session with a token
35
+ "claims": {
36
+ "birthdate": <string>, # subject's birth date
37
+ "email": <string>, # subject's email
38
+ "gender": <string>, # subject's gender
39
+ "name": <string>, # subject's name
40
+ "roles": <List[str]>, # subject roles
41
+ "nonce": <string>, # value used to associate a Client session with a token
42
+ ...
43
+ }
44
+ },
45
+ ...
46
+ }
46
47
 
47
48
  JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between
48
49
  two parties. It is fully described in the RFC 7519, issued by the Internet Engineering Task Force
@@ -51,24 +52,30 @@ class JwtData:
51
52
  as token-related and account-related. All times are UTC.
52
53
 
53
54
  Token-related claims are mostly required claims, and convey information about the token itself:
54
- "exp": <timestamp> # expiration time
55
- "iat": <timestamp> # issued at
56
- "iss": <string> # issuer (for remote providers, URL to obtain and validate the access tokens)
57
- "jti": <string> # JWT id
58
- "sub": <string> # subject (the account identification)
59
- "nat": <string> # nature of token (A: access; R: refresh) - locally issued tokens, only
60
- # optional:
61
- "aud": <string> # token audience
62
- "nbt": <timestamp> # not before time
55
+ "exp": <timestamp> # expiration time
56
+ "iat": <timestamp> # issued at
57
+ "iss": <string> # issuer (for remote providers, URL to obtain and validate the access tokens)
58
+ "jti": <string> # JWT id
59
+ "sub": <string> # subject (the account identification)
60
+ "nat": <string> # nature of token (A: access; R: refresh) - locally issued tokens, only
61
+ # optional:
62
+ "aud": <string> # token audience
63
+ "nbt": <timestamp> # not before time
63
64
 
64
65
  Account-related claims are optional claims, and convey information about the registered account they belong to.
65
66
  Alhough they can be freely specified, these are some of the most commonly used claims:
66
- "birthdate": <string> # subject's birth date
67
- "email": <string> # subject's email
68
- "gender": <string> # subject's gender
69
- "name": <string> # subject's name
70
- "roles": <List[str]> # subject roles
71
- "nonce": <string> # value used to associate a client session with a token
67
+ "birthdate": <string> # subject's birth date
68
+ "email": <string> # subject's email
69
+ "gender": <string> # subject's gender
70
+ "name": <string> # subject's name
71
+ "roles": <List[str]> # subject roles
72
+ "nonce": <string> # value used to associate a client session with a token
73
+
74
+ The token header has these items:
75
+ "alg": <string> # the algorithm used to sign the token (one of 'HS256', 'HS512', 'RSA256', 'RSA512')
76
+ "typ": <string> # the token type (fixed to 'JWT'
77
+ "kid": <string> # a reference to the encoding/decoding keys used
78
+ # (if issued by the local server, holds the public key, if assimetric keys were used)
72
79
  """
73
80
  def __init__(self) -> None:
74
81
  """
@@ -77,18 +84,18 @@ class JwtData:
77
84
  self.access_lock: Lock = Lock()
78
85
  self.access_data: dict[str, Any] = {}
79
86
 
80
- def add_access(self,
81
- account_id: str,
82
- reference_url: str,
83
- claims: dict[str, Any],
84
- access_max_age: int,
85
- refresh_max_age: int,
86
- grace_interval: int,
87
- token_audience: str,
88
- token_nonce: str,
89
- request_timeout: int,
90
- remote_provider: bool,
91
- logger: Logger = None) -> None:
87
+ def add_account(self,
88
+ account_id: str,
89
+ reference_url: str,
90
+ claims: dict[str, Any],
91
+ access_max_age: int,
92
+ refresh_max_age: int,
93
+ grace_interval: int,
94
+ token_audience: str,
95
+ token_nonce: str,
96
+ request_timeout: int,
97
+ remote_provider: bool,
98
+ logger: Logger = None) -> None:
92
99
  """
93
100
  Add to storage the parameters needed to produce and validate JWT tokens for *account_id*.
94
101
 
@@ -128,9 +135,9 @@ class JwtData:
128
135
  elif logger:
129
136
  logger.warning(f"JWT data already exists for '{account_id}'")
130
137
 
131
- def remove_access(self,
132
- account_id: str,
133
- logger: Logger) -> bool:
138
+ def remove_account(self,
139
+ account_id: str,
140
+ logger: Logger) -> bool:
134
141
  """
135
142
  Remove from storage the access data for *account_id*.
136
143
 
@@ -142,6 +149,12 @@ class JwtData:
142
149
  with self.access_lock:
143
150
  account_data = self.access_data.pop(account_id, None)
144
151
 
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)
145
158
  if logger:
146
159
  if account_data:
147
160
  logger.debug(f"Removed JWT data for '{account_id}'")
@@ -200,6 +213,7 @@ class JwtData:
200
213
  # obtain new tokens
201
214
  current_claims["jti"] = str_random(size=32,
202
215
  chars=string.ascii_letters + string.digits)
216
+ current_claims["sub"] = account_id
203
217
  current_claims["iss"] = account_data.get("reference-url")
204
218
 
205
219
  # where is the JWT service provider ?
@@ -224,55 +238,36 @@ class JwtData:
224
238
  # JWT service is being provided locally
225
239
  just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
226
240
  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}
227
244
 
228
- # retrieve the refresh token associated with the account id
229
- refresh_token: str | None = None
230
- if JWT_DB_ENGINE:
231
- from pypomes_db import db_select, db_delete
232
- if JWT_ROTATE_TOKENS:
233
- db_delete(errors=errors,
234
- delete_stmt=f"DELETE FROM {JWT_DB_TABLE} "
235
- f"WHERE {JWT_DB_COL_ACCOUNT} = '{account_id}'",
236
- logger=logger)
237
- else:
238
- recs: list[tuple[str]] = \
239
- db_select(errors=errors,
240
- sel_stmt=f"SELECT token FROM {JWT_DB_TABLE} "
241
- f"WHERE {JWT_DB_COL_ACCOUNT} = '{account_id}'",
242
- max_count=1,
243
- logger=logger)
244
- if recs:
245
- refresh_token = recs[0][0]
246
- if errors:
247
- raise RuntimeError(" - ".join(errors))
248
-
249
- # was it obtained ?
250
- if not refresh_token:
251
- # no, issue a new one
252
- current_claims["exp"] = just_now + account_data.get("refresh-max-age")
253
- current_claims["nat"] = "R"
254
- # may raise an exception
255
- refresh_token: str = jwt.encode(payload=current_claims,
256
- key=JWT_ENCODING_KEY,
257
- algorithm=JWT_DEFAULT_ALGORITHM)
258
- # persist the new refresh token
259
- if JWT_DB_ENGINE:
260
- from pypomes_db import db_insert
261
- db_insert(errors=errors,
262
- insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
263
- insert_data={JWT_DB_COL_ACCOUNT: account_id,
264
- JWT_DB_COL_TOKEN: refresh_token},
265
- logger=logger)
266
- if errors:
267
- raise RuntimeError(" - ".join(errors))
268
-
269
- # issue the access token
245
+ # issue the access token first
270
246
  current_claims["nat"] = "A"
271
247
  current_claims["exp"] = just_now + account_data.get("access-max-age")
272
248
  # may raise an exception
273
249
  access_token: str = jwt.encode(payload=current_claims,
274
250
  key=JWT_ENCODING_KEY,
275
- algorithm=JWT_DEFAULT_ALGORITHM)
251
+ algorithm=JWT_DEFAULT_ALGORITHM,
252
+ headers=token_header)
253
+
254
+ # then issue the refresh token
255
+ current_claims["exp"] = just_now + account_data.get("refresh-max-age")
256
+ current_claims["nat"] = "R"
257
+ # may raise an exception
258
+ refresh_token: str = jwt.encode(payload=current_claims,
259
+ 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))
270
+
276
271
  # return the token data
277
272
  result = {
278
273
  "access_token": access_token,
@@ -301,7 +296,9 @@ def _jwt_request_token(errors: list[str],
301
296
  Expected structure of the return data:
302
297
  {
303
298
  "access_token": <jwt-token>,
304
- "expires_in": <seconds-to-expiration>
299
+ "created_in": <timestamp>,
300
+ "expires_in": <seconds-to-expiration>,
301
+ "refresh_token": <token>
305
302
  }
306
303
  It is up to the invoker to make sure that the *claims* data conform to the requirements
307
304
  of the provider issuing the JWT token.
@@ -340,3 +337,68 @@ def _jwt_request_token(errors: list[str],
340
337
  errors.append(err_msg)
341
338
 
342
339
  return result
340
+
341
+
342
+ def _jwt_persist_token(errors: list[str],
343
+ account_id: str,
344
+ jwt_token: str,
345
+ logger: Logger = None) -> None:
346
+ """
347
+ Persist the given token, making sure that the account limit is adhered to.
348
+
349
+ :param errors: incidental errors
350
+ :param account_id: the account identification
351
+ :param jwt_token: the JWT token to persist
352
+ :param logger: optional logger
353
+ """
354
+ from pypomes_db import db_select, db_insert, db_delete
355
+ from .jwt_pomes import jwt_get_claims
356
+
357
+ # 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:
362
+ 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().decode()
401
+ db_insert(errors=errors,
402
+ insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
403
+ insert_data={"ds_hash": token_hash,
404
+ "ds_token": jwt_token})
pypomes_jwt/jwt_pomes.py CHANGED
@@ -1,3 +1,4 @@
1
+ import hashlib
1
2
  import jwt
2
3
  from flask import Request, Response, request
3
4
  from logging import Logger
@@ -31,6 +32,51 @@ def jwt_needed(func: callable) -> callable:
31
32
  return wrapper
32
33
 
33
34
 
35
+ def jwt_verify_request(request: Request,
36
+ logger: Logger = None) -> Response:
37
+ """
38
+ Verify wheher the HTTP *request* has the proper authorization, as per the JWT standard.
39
+
40
+ :param request: the request to be verified
41
+ :param logger: optional logger
42
+ :return: *None* if the request is valid, otherwise a *Response* object reporting the error
43
+ """
44
+ # initialize the return variable
45
+ result: Response | None = None
46
+
47
+ if logger:
48
+ logger.debug(msg="Validate a JWT token")
49
+ err_msg: str | None = None
50
+
51
+ # retrieve the authorization from the request header
52
+ auth_header: str = request.headers.get("Authorization")
53
+
54
+ # was a 'Bearer' authorization obtained ?
55
+ if auth_header and auth_header.startswith("Bearer "):
56
+ # yes, extract and validate the JWT access token
57
+ token: str = auth_header.split(" ")[1]
58
+ if logger:
59
+ logger.debug(msg=f"Token is '{token}'")
60
+ errors: list[str] = []
61
+ jwt_validate_token(errors=errors,
62
+ nature="A",
63
+ token=token)
64
+ if errors:
65
+ err_msg = "; ".join(errors)
66
+ else:
67
+ # no 'Bearer' found, report the error
68
+ err_msg = "Request header has no 'Bearer' data"
69
+
70
+ # log the error and deny the authorization
71
+ if err_msg:
72
+ if logger:
73
+ logger.error(msg=err_msg)
74
+ result = Response(response="Authorization failed",
75
+ status=401)
76
+
77
+ return result
78
+
79
+
34
80
  def jwt_assert_access(account_id: str) -> bool:
35
81
  """
36
82
  Determine whether access for *account_id* has been established.
@@ -68,7 +114,7 @@ def jwt_set_access(account_id: str,
68
114
  :param logger: optional logger
69
115
  """
70
116
  if logger:
71
- logger.debug(msg=f"Register access data for '{account_id}'")
117
+ logger.debug(msg=f"Register account data for '{account_id}'")
72
118
 
73
119
  # extract the claims provided in the reference URL's query string
74
120
  pos: int = reference_url.find("?")
@@ -79,21 +125,21 @@ def jwt_set_access(account_id: str,
79
125
  reference_url = reference_url[:pos]
80
126
 
81
127
  # register the JWT service
82
- __jwt_data.add_access(account_id=account_id,
83
- reference_url=reference_url,
84
- claims=claims,
85
- access_max_age=access_max_age,
86
- refresh_max_age=refresh_max_age,
87
- grace_interval=grace_interval,
88
- token_audience=token_audience,
89
- token_nonce=token_nonce,
90
- request_timeout=request_timeout,
91
- remote_provider=remote_provider,
92
- logger=logger)
93
-
94
-
95
- def jwt_remove_access(account_id: str,
96
- logger: Logger = None) -> bool:
128
+ __jwt_data.add_account(account_id=account_id,
129
+ reference_url=reference_url,
130
+ claims=claims,
131
+ access_max_age=access_max_age,
132
+ refresh_max_age=refresh_max_age,
133
+ grace_interval=grace_interval,
134
+ token_audience=token_audience,
135
+ token_nonce=token_nonce,
136
+ request_timeout=request_timeout,
137
+ remote_provider=remote_provider,
138
+ logger=logger)
139
+
140
+
141
+ def jwt_remove_account(account_id: str,
142
+ logger: Logger = None) -> bool:
97
143
  """
98
144
  Remove from storage the JWT access data for *account_id*.
99
145
 
@@ -104,8 +150,8 @@ def jwt_remove_access(account_id: str,
104
150
  if logger:
105
151
  logger.debug(msg=f"Remove access data for '{account_id}'")
106
152
 
107
- return __jwt_data.remove_access(account_id=account_id,
108
- logger=logger)
153
+ return __jwt_data.remove_account(account_id=account_id,
154
+ logger=logger)
109
155
 
110
156
 
111
157
  def jwt_validate_token(errors: list[str] | None,
@@ -136,7 +182,7 @@ def jwt_validate_token(errors: list[str] | None,
136
182
  claims: dict[str, Any] = jwt.decode(jwt=token,
137
183
  key=JWT_DECODING_KEY,
138
184
  algorithms=[JWT_DEFAULT_ALGORITHM])
139
- if nature and "nat" in claims and nature != claims.get("nat"):
185
+ if nature and nature != claims.get("nat"):
140
186
  nat: str = "an access" if nature == "A" else "a refresh"
141
187
  err_msg = f"Token is not {nat} token"
142
188
  except Exception as e:
@@ -153,16 +199,18 @@ def jwt_validate_token(errors: list[str] | None,
153
199
  return err_msg is None
154
200
 
155
201
 
156
- def jwt_revoke_tokens(errors: list[str] | None,
157
- account_id: str,
158
- logger: Logger = None) -> bool:
202
+ def jwt_revoke_token(errors: list[str] | None,
203
+ account_id: str,
204
+ refresh_token: str,
205
+ logger: Logger = None) -> bool:
159
206
  """
160
- Revoke all refresh tokens associated with *account_id*.
207
+ Revoke the *refresh_token* associated with *account_id*.
161
208
 
162
209
  Revoke operations require access to a database table defined by *JWT_DB_TABLE*.
163
210
 
164
211
  :param errors: incidental error messages
165
212
  :param account_id: the account identification
213
+ :param refresh_token: the token to be revolked
166
214
  :param logger: optional logger
167
215
  :return: *True* if operation could be performed, *False* otherwise
168
216
  """
@@ -170,16 +218,25 @@ def jwt_revoke_tokens(errors: list[str] | None,
170
218
  result: bool = False
171
219
 
172
220
  if logger:
173
- logger.debug(msg=f"Revoking refresh tokens of '{account_id}'")
221
+ logger.debug(msg=f"Revoking refresh token of '{account_id}'")
174
222
 
175
223
  op_errors: list[str] = []
176
224
  if JWT_DB_ENGINE:
177
- from pypomes_db import db_delete
178
- delete_stmt: str = (f"DELETE FROM {JWT_DB_TABLE} "
179
- f"WHERE {JWT_DB_COL_ACCOUNT} = '{account_id}'")
180
- db_delete(errors=op_errors,
181
- delete_stmt=delete_stmt,
182
- logger=logger)
225
+ from pypomes_db import db_exists, db_delete
226
+ # ruff: noqa: S324
227
+ hasher = hashlib.new(name="md5",
228
+ data=refresh_token.encode())
229
+ token_hash: str = hasher.digest().decode()
230
+ if db_exists(errors=op_errors,
231
+ table=JWT_DB_TABLE,
232
+ where_data={"ds_hash": token_hash},
233
+ logger=logger):
234
+ db_delete(errors=errors,
235
+ delete_stmt=f"DELETE FROM {JWT_DB_TABLE}",
236
+ where_data={"ds_hash": token_hash},
237
+ logger=logger)
238
+ elif not op_errors:
239
+ op_errors.append("Token was not found")
183
240
  else:
184
241
  op_errors.append("Database access for token revocation has not been specified")
185
242
 
@@ -233,7 +290,7 @@ def jwt_get_tokens(errors: list[str] | None,
233
290
  recs: list[tuple[str]] = db_select(errors=op_errors,
234
291
  sel_stmt=f"SELECT {JWT_DB_COL_TOKEN} "
235
292
  f"FROM {JWT_DB_TABLE}",
236
- where_data={JWT_DB_COL_ACCOUNT: f"'{account_id}'"},
293
+ where_data={JWT_DB_COL_ACCOUNT: account_id},
237
294
  logger=logger)
238
295
  if not op_errors and \
239
296
  (len(recs) == 0 or recs[0][0] != refresh_token):
@@ -247,7 +304,8 @@ def jwt_get_tokens(errors: list[str] | None,
247
304
  if not op_errors:
248
305
  try:
249
306
  result = __jwt_data.issue_tokens(account_id=account_id,
250
- account_claims=account_claims)
307
+ account_claims=account_claims,
308
+ logger=logger)
251
309
  if logger:
252
310
  logger.debug(msg=f"Data is '{result}'")
253
311
  except Exception as e:
@@ -265,12 +323,44 @@ def jwt_get_tokens(errors: list[str] | None,
265
323
 
266
324
  def jwt_get_claims(errors: list[str] | None,
267
325
  token: str,
326
+ validate: bool = True,
268
327
  logger: Logger = None) -> dict[str, Any]:
269
328
  """
270
329
  Obtain and return the claims set of a JWT *token*.
271
330
 
331
+ If *validate* is set to *True*, tha following pieces of information are verified:
332
+ - the token was issued and signed by the local provider, and is not corrupted
333
+ - the claim 'exp' is present and is in the future
334
+ - the claim 'nbf' is present and is in the past
335
+
336
+ Structure of the returned data:
337
+ {
338
+ "header": {
339
+ "alg": "HS256",
340
+ "typ": "JWT",
341
+ "kid": "rt466ytRTYH64577uydhDFGHDYJH2341"
342
+ },
343
+ "payload": {
344
+ "birthdate": "1980-01-01",
345
+ "email": "jdoe@mail.com",
346
+ "exp": 1516640454,
347
+ "iat": 1516239022,
348
+ "iss": "https://my_id_provider/issue",
349
+ "jti": "Uhsdfgr67FGH567qwSDF33er89retert",
350
+ "gender": "M,
351
+ "name": "John Doe",
352
+ "nbt": 1516249022
353
+ "sub": "1234567890",
354
+ "roles": [
355
+ "administrator",
356
+ "operator"
357
+ ]
358
+ }
359
+ }
360
+
272
361
  :param errors: incidental error messages
273
362
  :param token: the token to be inspected for claims
363
+ :param validate: If *True*, verifies the token's data
274
364
  :param logger: optional logger
275
365
  :return: the token's claimset, or *None* if error
276
366
  """
@@ -281,14 +371,26 @@ def jwt_get_claims(errors: list[str] | None,
281
371
  logger.debug(msg=f"Retrieve claims for token '{token}'")
282
372
 
283
373
  try:
284
- claims: dict[str, Any] = jwt.decode(jwt=token,
285
- options={"verify_signature": False})
286
- if claims.get("nat") in ["A", "R"]:
287
- result = jwt.decode(jwt=token,
288
- key=JWT_DECODING_KEY,
289
- algorithms=[JWT_DEFAULT_ALGORITHM])
374
+ # retrieve the token's payload
375
+ if validate:
376
+ payload: dict[str, Any] = jwt.decode(jwt=token,
377
+ options={
378
+ "verify_signature": True,
379
+ "verify_exp": True,
380
+ "verify_nbf": True
381
+ },
382
+ key=JWT_DECODING_KEY,
383
+ require=["exp", "nbf"],
384
+ algorithms=[JWT_DEFAULT_ALGORITHM])
290
385
  else:
291
- result = claims
386
+ payload: dict[str, Any] = jwt.decode(jwt=token,
387
+ options={"verify_signature": False})
388
+ # retrieve the token's header
389
+ header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
390
+ result = {
391
+ "header": header,
392
+ "payload": payload
393
+ }
292
394
  except Exception as e:
293
395
  if logger:
294
396
  logger.error(msg=str(e))
@@ -296,48 +398,3 @@ def jwt_get_claims(errors: list[str] | None,
296
398
  errors.append(str(e))
297
399
 
298
400
  return result
299
-
300
-
301
- def jwt_verify_request(request: Request,
302
- logger: Logger = None) -> Response:
303
- """
304
- Verify wheher the HTTP *request* has the proper authorization, as per the JWT standard.
305
-
306
- :param request: the request to be verified
307
- :param logger: optional logger
308
- :return: *None* if the request is valid, otherwise a *Response* object reporting the error
309
- """
310
- # initialize the return variable
311
- result: Response | None = None
312
-
313
- if logger:
314
- logger.debug(msg="Validate a JWT token")
315
- err_msg: str | None = None
316
-
317
- # retrieve the authorization from the request header
318
- auth_header: str = request.headers.get("Authorization")
319
-
320
- # was a 'Bearer' authorization obtained ?
321
- if auth_header and auth_header.startswith("Bearer "):
322
- # yes, extract and validate the JWT access token
323
- token: str = auth_header.split(" ")[1]
324
- if logger:
325
- logger.debug(msg=f"Token is '{token}'")
326
- errors: list[str] = []
327
- jwt_validate_token(errors=errors,
328
- nature="A",
329
- token=token)
330
- if errors:
331
- err_msg = "; ".join(errors)
332
- else:
333
- # no 'Bearer' found, report the error
334
- err_msg = "Request header has no 'Bearer' data"
335
-
336
- # log the error and deny the authorization
337
- if err_msg:
338
- if logger:
339
- logger.error(msg=err_msg)
340
- result = Response(response="Authorization failed",
341
- status=401)
342
-
343
- return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_jwt
3
- Version: 0.7.6
3
+ Version: 0.7.9
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
@@ -0,0 +1,8 @@
1
+ pypomes_jwt/__init__.py,sha256=xUDd_xphRQFHuTxrvlQxO-mHIXgTqZjHWHMgp5gRrXU,1130
2
+ pypomes_jwt/jwt_constants.py,sha256=6-Jw4ORgf32hRWnaGyVISXMJMtTBk7LdKl3RrDy7Ll0,4328
3
+ pypomes_jwt/jwt_data.py,sha256=gyhGquSQbHevOKIoXmAmjMSwCjXB7pYbI2sY-7sGGO8,19158
4
+ pypomes_jwt/jwt_pomes.py,sha256=xNBlHhvrOH07WP6hKE0PyDl4fKSl0R1Xg7AQO_1b1uo,15201
5
+ pypomes_jwt-0.7.9.dist-info/METADATA,sha256=rXu25F1ufUL_U7frUaV1DMAj27pH8gvPX-TIA0qLlrg,599
6
+ pypomes_jwt-0.7.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
+ pypomes_jwt-0.7.9.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
8
+ pypomes_jwt-0.7.9.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- pypomes_jwt/__init__.py,sha256=dkWeYPNwypjwFuTjx4YtC8QV9ihykF4xcJJ7x86Wc5g,1130
2
- pypomes_jwt/jwt_constants.py,sha256=xUX2raEaDUPJsjAm78lQ0APs4KSs5GYBxSPC5QKrrFE,4327
3
- pypomes_jwt/jwt_data.py,sha256=YH3v8zvOURkB_o0XLMu2y2sFkKCxZBmbyWU5rC2gre4,16419
4
- pypomes_jwt/jwt_pomes.py,sha256=eRQSLA8DVzr9MfVOh5bl6Zz3iOQtRIAQjzIfoz6Fj9o,12915
5
- pypomes_jwt-0.7.6.dist-info/METADATA,sha256=5N0G1VHBXKHiH-QAnUBC9UgeqpjOh9-4-BYMtAl680U,599
6
- pypomes_jwt-0.7.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
- pypomes_jwt-0.7.6.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
8
- pypomes_jwt-0.7.6.dist-info/RECORD,,