pypomes-jwt 0.8.9__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pypomes-jwt might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_jwt
3
- Version: 0.8.9
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.6
16
+ Requires-Dist: pypomes-db>=1.9.7
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "pypomes_jwt"
9
- version = "0.8.9"
9
+ version = "0.9.1"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -22,7 +22,7 @@ dependencies = [
22
22
  "PyJWT>=2.10.1",
23
23
  "cryptography>=44.0.2",
24
24
  "pypomes_core>=1.8.5",
25
- "pypomes_db>=1.9.6"
25
+ "pypomes_db>=1.9.7"
26
26
  ]
27
27
 
28
28
  [project.urls]
@@ -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
- JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
7
- JWT_ENCODING_KEY, JWT_DECODING_KEY
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
- jwt_get_tokens, jwt_get_claims, jwt_validate_token, jwt_revoke_token
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
- "jwt_get_tokens", "jwt_get_claims", "jwt_validate_token", "jwt_revoke_token"
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
@@ -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, Literal
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 .jwt_data import JwtData
14
+ from .jwt_registry import JwtRegistry
15
15
 
16
16
  # the JWT data object
17
- __jwt_data: JwtData = JwtData()
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="Token was found")
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 __jwt_data.access_data.get(account_id) is not None
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
- __jwt_data.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)
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 __jwt_data.remove_account(account_id=account_id,
155
- logger=logger)
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: Literal["A", "R"] = None,
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: optionally validate the token's nature ("A": access token, "R": refresh token)
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: int = int(token_header.get("kid") or 0)
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 (nature == "R" and not token_kid) or (nature == "A" and token_kid):
189
- nat: str = "an access" if nature == "A" else "a refresh"
190
- op_errors.append(f"Token is not {nat} token")
191
- elif token_kid:
192
- where_data: dict[str, str] = {JWT_DB_COL_KID: token_kid}
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 = base64.urlsafe_b64decode(recs[0][1])
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: int = int(token_claims["header"].get("kid") or 0)
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 jwt_get_tokens(errors: list[str] | None,
302
- account_id: str,
303
- account_claims: dict[str, Any] = None,
304
- refresh_token: str = None,
305
- logger: Logger = None) -> dict[str, Any]:
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
- Issue or refresh, and return, the JWT token data associated with *account_id*.
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"Retrieve JWT token data for '{account_id}'")
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 = __jwt_data.issue_tokens(account_id=account_id,
349
- account_claims=account_claims,
350
- logger=logger)
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": "1234"
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 JwtData:
21
+ class JwtRegistry:
22
22
  """
23
- Shared JWT data for security token access.
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
- "nat": <string> # nature of token (A: access; R: refresh) - locally issued tokens, only
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
- "birthdate": <string> # subject's birth date
75
- "email": <string> # subject's email
76
- "gender": <string> # subject's gender
77
- "name": <string> # subject's name
78
- "roles": <List[str]> # subject roles
79
- "nonce": <string> # value used to associate a client session with a token
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 encoding/decoding keys used
85
- # (if issued by the local server, holds the public key, if assimetric keys were used)
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, or *None* if error
192
- :raises InvalidTokenError: token is invalid
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.access_data.get(account_id)
210
-
211
- # was the JWT data obtained ?
212
- if account_data:
213
- # yes, proceed
214
- errors: list[str] = []
215
- current_claims: dict[str, Any] = account_data.get("claims").copy()
216
- if account_claims:
217
- current_claims.update(account_claims)
218
- current_claims["jti"] = str_random(size=32,
219
- chars=string.ascii_letters + string.digits)
220
- current_claims["sub"] = account_id
221
- current_claims["iss"] = account_data.get("reference-url")
222
-
223
- # where is the JWT service provider ?
224
- if account_data.get("remote-provider"):
225
- # JWT service is being provided by a remote server
226
- # Structure of the return data:
227
- # {
228
- # "access_token": <jwt-token>,
229
- # "created_in": <timestamp>,
230
- # "expires_in": <seconds-to-expiration>,
231
- # "refresh_token": <jwt-token>
232
- # ...
233
- # }
234
- result = _jwt_request_token(errors=errors,
235
- reference_url=current_claims.get("iss"),
236
- claims=current_claims,
237
- timeout=account_data.get("request-timeout"),
238
- logger=logger)
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
- current_claims["valid-until"] = datetime.fromtimestamp(timestamp=current_claims["exp"],
251
- tz=timezone.utc).isoformat()
252
- # may raise an exception
253
- refresh_token: str = jwt.encode(payload=current_claims,
254
- key=JWT_ENCODING_KEY,
255
- algorithm=JWT_DEFAULT_ALGORITHM)
256
- # obtain a DB connection (may raise an exception)
257
- db_conn: Any = db_connect(errors=errors,
258
- logger=logger)
259
- # persist the candidate token (may raise an exception)
260
- token_id: int = _jwt_persist_token(errors=errors,
261
- account_id=account_id,
262
- jwt_token=refresh_token,
263
- db_conn=db_conn,
264
- logger=logger)
265
- # issue the definitive refresh token
266
- refresh_token = jwt.encode(payload=current_claims,
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": str(token_id)})
270
- # persist it
271
- db_update(errors=errors,
272
- update_stmt=f"UPDATE {JWT_DB_TABLE}",
273
- update_data={JWT_DB_COL_TOKEN: refresh_token},
274
- where_data={JWT_DB_COL_KID: token_id},
275
- connection=db_conn,
276
- logger=logger)
277
- # commit the transaction
278
- db_commit(errors=errors,
279
- connection=db_conn,
280
- logger=logger)
281
- if errors:
282
- raise RuntimeError("; ".join(errors))
283
-
284
- # issue the access token
285
- current_claims["exp"] = just_now + account_data.get("access-max-age")
286
- # may raise an exception
287
- access_token: str = jwt.encode(payload=current_claims,
288
- key=JWT_ENCODING_KEY,
289
- algorithm=JWT_DEFAULT_ALGORITHM)
290
- # return the token data
291
- result = {
292
- "access_token": access_token,
293
- "created_in": current_claims.get("iat"),
294
- "expires_in": current_claims.get("exp"),
295
- "refresh_token": refresh_token
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 revocation database
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: base64.urlsafe_b64encode(JWT_DECODING_KEY).decode()},
524
+ JWT_DB_COL_DECODER: urlsafe_b64encode(JWT_DECODING_KEY).decode()},
457
525
  connection=db_conn,
458
526
  logger=logger)
459
527
  if errors:
File without changes
File without changes
File without changes
File without changes