pypomes-jwt 0.7.0__py3-none-any.whl → 0.7.2__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 +16 -14
- pypomes_jwt/jwt_constants.py +76 -0
- pypomes_jwt/jwt_data.py +206 -297
- pypomes_jwt/jwt_pomes.py +175 -202
- {pypomes_jwt-0.7.0.dist-info → pypomes_jwt-0.7.2.dist-info}/METADATA +2 -2
- pypomes_jwt-0.7.2.dist-info/RECORD +8 -0
- pypomes_jwt-0.7.0.dist-info/RECORD +0 -7
- {pypomes_jwt-0.7.0.dist-info → pypomes_jwt-0.7.2.dist-info}/WHEEL +0 -0
- {pypomes_jwt-0.7.0.dist-info → pypomes_jwt-0.7.2.dist-info}/licenses/LICENSE +0 -0
pypomes_jwt/__init__.py
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
|
-
from .
|
|
2
|
-
|
|
1
|
+
from .jwt_constants import (
|
|
2
|
+
JWT_DB_ENGINE, JWT_DB_HOST, JWT_DB_NAME,
|
|
3
|
+
JWT_DB_PORT, JWT_DB_USER, JWT_DB_PWD,
|
|
4
|
+
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
|
|
5
|
+
JWT_ENCODING_KEY, JWT_DECODING_KEY
|
|
3
6
|
)
|
|
4
7
|
from .jwt_pomes import (
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
jwt_get_token_data, jwt_get_token_claims,
|
|
9
|
-
jwt_assert_access, jwt_set_access, jwt_remove_access
|
|
8
|
+
jwt_needed, jwt_verify_request,
|
|
9
|
+
jwt_get_tokens, jwt_get_claims, jwt_validate_token,
|
|
10
|
+
jwt_assert_access, jwt_set_access, jwt_remove_access, jwt_revoke_tokens
|
|
10
11
|
)
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
13
|
-
#
|
|
14
|
-
"
|
|
15
|
-
|
|
14
|
+
# jwt_constants
|
|
15
|
+
"JWT_DB_ENGINE", "JWT_DB_HOST", "JWT_DB_NAME",
|
|
16
|
+
"JWT_DB_PORT", "JWT_DB_USER", "JWT_DB_PWD",
|
|
16
17
|
"JWT_ACCESS_MAX_AGE", "JWT_REFRESH_MAX_AGE",
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
18
|
+
"JWT_ENCODING_KEY", "JWT_DECODING_KEY",
|
|
19
|
+
# jwt_pomes
|
|
20
|
+
"jwt_needed", "jwt_verify_request",
|
|
21
|
+
"jwt_get_tokens", "jwt_get_claims", "jwt_validate_token",
|
|
22
|
+
"jwt_assert_access", "jwt_set_access", "jwt_remove_access", "jwt_revoke_tokens"
|
|
21
23
|
]
|
|
22
24
|
|
|
23
25
|
from importlib.metadata import version
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from cryptography.hazmat.primitives import serialization
|
|
2
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
|
4
|
+
from pypomes_core import (
|
|
5
|
+
APP_PREFIX,
|
|
6
|
+
env_get_str, env_get_bytes, env_get_int, env_get_bool
|
|
7
|
+
)
|
|
8
|
+
from secrets import token_bytes
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
# database specs for token persistence
|
|
12
|
+
JWT_DB_HOST: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_HOST")
|
|
13
|
+
JWT_DB_NAME: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_NAME")
|
|
14
|
+
JWT_DB_PORT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_DB_PORT")
|
|
15
|
+
JWT_DB_USER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_USER")
|
|
16
|
+
JWT_DB_PWD: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_PWD")
|
|
17
|
+
JWT_DB_CLIENT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_CLIENT") # for Oracle, only
|
|
18
|
+
JWT_DB_DRIVER: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_DRIVER") # for SQLServer, only
|
|
19
|
+
JWT_DB_TABLE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DB_TABLE")
|
|
20
|
+
JWT_ROTATE_TOKENS: Final[bool] = env_get_bool(key=f"{APP_PREFIX}_JWT_ROTATE_TOKENS",
|
|
21
|
+
def_value=True)
|
|
22
|
+
|
|
23
|
+
__db_engine: str | None = env_get_str(key=f"{APP_PREFIX}_JWT_DB_ENGINE")
|
|
24
|
+
__rotate_tokens: bool = False
|
|
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),
|
|
29
|
+
db_name=JWT_DB_NAME,
|
|
30
|
+
db_user=JWT_DB_USER,
|
|
31
|
+
db_pwd=JWT_DB_PWD,
|
|
32
|
+
db_host=JWT_DB_HOST,
|
|
33
|
+
db_port=JWT_DB_PORT,
|
|
34
|
+
db_client=JWT_DB_CLIENT,
|
|
35
|
+
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
|
+
JWT_DB_ENGINE: Final[DbEngine] = DbEngine(__db_engine) if __db_engine else None
|
|
46
|
+
|
|
47
|
+
# one of HS256, HS512, RSA256, RSA512
|
|
48
|
+
JWT_DEFAULT_ALGORITHM: Final[str] = env_get_str(key=f"{APP_PREFIX}_JWT_DEFAULT_ALGORITHM",
|
|
49
|
+
def_value="HS256")
|
|
50
|
+
# recommended: between 5 min and 1 hour (set to 5 min)
|
|
51
|
+
JWT_ACCESS_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCESS_MAX_AGE",
|
|
52
|
+
def_value=300)
|
|
53
|
+
# recommended: at least 2 hours (set to 24 hours)
|
|
54
|
+
JWT_REFRESH_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_REFRESH_MAX_AGE",
|
|
55
|
+
def_value=86400)
|
|
56
|
+
|
|
57
|
+
# recommended: allow the encode and decode keys to be generated anew when app starts
|
|
58
|
+
__encoding_key: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_ENCODE_KEY")
|
|
59
|
+
__decoding_key: bytes
|
|
60
|
+
if JWT_DEFAULT_ALGORITHM in ["HS256", "HS512"]:
|
|
61
|
+
if not __encoding_key:
|
|
62
|
+
__encoding_key = token_bytes(nbytes=32)
|
|
63
|
+
__decoding_key = __encoding_key
|
|
64
|
+
else:
|
|
65
|
+
__decoding_key: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_DECODE_KEY")
|
|
66
|
+
if not __encoding_key or not __decoding_key:
|
|
67
|
+
__priv_key: RSAPrivateKey = rsa.generate_private_key(public_exponent=65537,
|
|
68
|
+
key_size=2048)
|
|
69
|
+
__encoding_key = __priv_key.private_bytes(encoding=serialization.Encoding.PEM,
|
|
70
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
71
|
+
encryption_algorithm=serialization.NoEncryption())
|
|
72
|
+
__pub_key: RSAPublicKey = __priv_key.public_key()
|
|
73
|
+
__decoding_key = __pub_key.public_bytes(encoding=serialization.Encoding.PEM,
|
|
74
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
75
|
+
JWT_ENCODING_KEY: Final[bytes] = __encoding_key
|
|
76
|
+
JWT_DECODING_KEY: Final[bytes] = __decoding_key
|
pypomes_jwt/jwt_data.py
CHANGED
|
@@ -2,12 +2,16 @@ import jwt
|
|
|
2
2
|
import requests
|
|
3
3
|
import string
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
|
-
from jwt.exceptions import InvalidTokenError
|
|
6
5
|
from logging import Logger
|
|
7
6
|
from pypomes_core import str_random
|
|
8
7
|
from requests import Response
|
|
9
8
|
from threading import Lock
|
|
10
|
-
from typing import Any
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .jwt_constants import (
|
|
12
|
+
JWT_DEFAULT_ALGORITHM, JWT_ENCODING_KEY,
|
|
13
|
+
JWT_ROTATE_TOKENS, JWT_DB_ENGINE, JWT_DB_TABLE
|
|
14
|
+
)
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
class JwtData:
|
|
@@ -16,171 +20,153 @@ class JwtData:
|
|
|
16
20
|
|
|
17
21
|
Instance variables:
|
|
18
22
|
- access_lock: lock for safe multi-threading access
|
|
19
|
-
- access_data:
|
|
20
|
-
|
|
21
|
-
{
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"rsa-public-key": <bytes>, # RSA public key
|
|
32
|
-
},
|
|
33
|
-
"reserved-claims": { # reserved claims
|
|
34
|
-
"exp": <timestamp>, # expiration time
|
|
35
|
-
"iat": <timestamp> # issued at
|
|
36
|
-
"iss": <string>, # issuer (for remote providers, URL to obtain and validate the access tokens)
|
|
37
|
-
"jti": <string>, # JWT id
|
|
38
|
-
"sub": <string> # subject (the account identification)
|
|
39
|
-
# not used:
|
|
40
|
-
# "aud": <string> # audience
|
|
41
|
-
# "nbt": <timestamp> # not before time
|
|
42
|
-
},
|
|
43
|
-
"public-claims": { # public claims (may be empty)
|
|
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": {
|
|
44
35
|
"birthdate": <string>, # subject's birth date
|
|
45
36
|
"email": <string>, # subject's email
|
|
46
37
|
"gender": <string>, # subject's gender
|
|
47
38
|
"name": <string>, # subject's name
|
|
48
|
-
"roles": <List[str]
|
|
49
|
-
|
|
50
|
-
"custom-claims": { # custom claims (may be empty)
|
|
51
|
-
"<custom-claim-key-1>": "<custom-claim-value-1>",
|
|
39
|
+
"roles": <List[str]>, # subject roles
|
|
40
|
+
"nonce": <string>, # value used to associate a Client session with a token
|
|
52
41
|
...
|
|
53
|
-
"<custom-claim-key-n>": "<custom-claim-value-n>"
|
|
54
42
|
}
|
|
55
43
|
},
|
|
56
44
|
...
|
|
57
|
-
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between
|
|
48
|
+
two parties. It is fully described in the RFC 7519, issued by the Internet Engineering Task Force
|
|
49
|
+
(see https://www.rfc-editor.org/rfc/rfc7519.html).
|
|
50
|
+
In this context, claims are pieces of information a token bears, and herein are loosely classified
|
|
51
|
+
as token-related and account-related. All times are UTC.
|
|
52
|
+
|
|
53
|
+
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
|
|
63
|
+
|
|
64
|
+
Account-related claims are optional claims, and convey information about the registered account they belong to.
|
|
65
|
+
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
|
|
58
72
|
"""
|
|
59
73
|
def __init__(self) -> None:
|
|
60
74
|
"""
|
|
61
75
|
Initizalize the token access data.
|
|
62
76
|
"""
|
|
63
77
|
self.access_lock: Lock = Lock()
|
|
64
|
-
self.access_data:
|
|
65
|
-
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
logger: Logger = None) -> None:
|
|
78
|
+
self.access_data: dict[str, Any] = {}
|
|
79
|
+
|
|
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:
|
|
79
92
|
"""
|
|
80
93
|
Add to storage the parameters needed to produce and validate JWT tokens for *account_id*.
|
|
81
94
|
|
|
82
|
-
The parameter *claims* may contain
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
Presently, the *refresh_max_age* data is not relevant, as the authorization parameters in *claims*
|
|
87
|
-
(typically, an acess-key/hs-secret-key pair), have been previously validated elsewhere.
|
|
88
|
-
This situation might change in the future.
|
|
95
|
+
The parameter *claims* may contain account-related claims, only. Ideally, it should contain,
|
|
96
|
+
at a minimum, "birthdate", "email", "gender", "name", and "roles".
|
|
97
|
+
If the token provider is local, then the token-related claims are created at token issuing time.
|
|
98
|
+
If the token provider is remote, all claims are sent to it at token request time.
|
|
89
99
|
|
|
90
100
|
:param account_id: the account identification
|
|
91
101
|
:param reference_url: the reference URL (for remote providers, URL to obtain and validate the JWT tokens)
|
|
92
102
|
:param claims: the JWT claimset, as key-value pairs
|
|
93
|
-
:param
|
|
94
|
-
:param
|
|
95
|
-
:param
|
|
96
|
-
:param
|
|
97
|
-
:param
|
|
98
|
-
:param rsa_public_key: public key for RSA authentication
|
|
103
|
+
:param access_max_age: access token duration, in seconds
|
|
104
|
+
:param refresh_max_age: refresh token duration, in seconds
|
|
105
|
+
:param grace_interval: time to wait for token to be valid, in seconds
|
|
106
|
+
:param token_audience: the audience the token is intended for
|
|
107
|
+
:param token_nonce: optional value used to associate a client session with a token
|
|
99
108
|
:param request_timeout: timeout for the requests to the reference URL
|
|
100
109
|
:param remote_provider: whether the JWT provider is a remote server
|
|
101
110
|
:param logger: optional logger
|
|
102
111
|
"""
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
"jti": "<jwt-id>",
|
|
126
|
-
}
|
|
127
|
-
custom_claims: dict[str, Any] = {}
|
|
128
|
-
public_claims: dict[str, Any] = {}
|
|
129
|
-
for key, value in claims.items():
|
|
130
|
-
if key in ["birthdate", "email", "gender", "name", "roles"]:
|
|
131
|
-
public_claims[key] = value
|
|
132
|
-
else:
|
|
133
|
-
custom_claims[key] = value
|
|
134
|
-
# store access data
|
|
135
|
-
item_data = {
|
|
136
|
-
"control-data": control_data,
|
|
137
|
-
"reserved-claims": reserved_claims,
|
|
138
|
-
"public-claims": public_claims,
|
|
139
|
-
"custom-claims": custom_claims
|
|
140
|
-
}
|
|
141
|
-
with self.access_lock:
|
|
142
|
-
self.access_data.append(item_data)
|
|
143
|
-
if logger:
|
|
144
|
-
logger.debug(f"JWT data added for '{account_id}': {item_data}")
|
|
145
|
-
elif logger:
|
|
146
|
-
logger.warning(f"JWT data already exists for '{account_id}'")
|
|
147
|
-
|
|
148
|
-
def remove_access_data(self,
|
|
149
|
-
account_id: str,
|
|
150
|
-
logger: Logger) -> None:
|
|
112
|
+
# build and store the access data for the account
|
|
113
|
+
with self.access_lock:
|
|
114
|
+
if account_id not in self.access_data:
|
|
115
|
+
self.access_data[account_id] = {
|
|
116
|
+
"reference_url": reference_url,
|
|
117
|
+
"access-max-age": access_max_age,
|
|
118
|
+
"refresh-max-age": refresh_max_age,
|
|
119
|
+
"grace-interval": grace_interval,
|
|
120
|
+
"token-audience": token_audience,
|
|
121
|
+
"token-nonce": token_nonce,
|
|
122
|
+
"request-timeout": request_timeout,
|
|
123
|
+
"remote-provider": remote_provider,
|
|
124
|
+
"claims": claims or {}
|
|
125
|
+
}
|
|
126
|
+
if logger:
|
|
127
|
+
logger.debug(f"JWT data added for '{account_id}'")
|
|
128
|
+
elif logger:
|
|
129
|
+
logger.warning(f"JWT data already exists for '{account_id}'")
|
|
130
|
+
|
|
131
|
+
def remove_access(self,
|
|
132
|
+
account_id: str,
|
|
133
|
+
logger: Logger) -> bool:
|
|
151
134
|
"""
|
|
152
135
|
Remove from storage the access data for *account_id*.
|
|
153
136
|
|
|
154
137
|
:param account_id: the account identification
|
|
155
138
|
:param logger: optional logger
|
|
139
|
+
return: *True* if the access data was removed, *False* otherwise
|
|
156
140
|
"""
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if logger:
|
|
141
|
+
account_data: dict[str, Any] | None
|
|
142
|
+
with self.access_lock:
|
|
143
|
+
account_data = self.access_data.pop(account_id, None)
|
|
144
|
+
|
|
145
|
+
if logger:
|
|
146
|
+
if account_data:
|
|
164
147
|
logger.debug(f"Removed JWT data for '{account_id}'")
|
|
165
|
-
|
|
166
|
-
|
|
148
|
+
else:
|
|
149
|
+
logger.warning(f"No JWT data found for '{account_id}'")
|
|
167
150
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
151
|
+
return account_data is not None
|
|
152
|
+
|
|
153
|
+
def issue_tokens(self,
|
|
154
|
+
account_id: str,
|
|
155
|
+
account_claims: dict[str, Any] = None,
|
|
156
|
+
logger: Logger = None) -> dict[str, Any]:
|
|
172
157
|
"""
|
|
173
|
-
|
|
158
|
+
Issue and return the JWT access and refresh tokens for *account_id*.
|
|
174
159
|
|
|
175
160
|
Structure of the return data:
|
|
176
161
|
{
|
|
177
162
|
"access_token": <jwt-token>,
|
|
178
163
|
"created_in": <timestamp>,
|
|
179
|
-
"expires_in": <seconds-to-expiration
|
|
164
|
+
"expires_in": <seconds-to-expiration>,
|
|
165
|
+
"refresh_token": <jwt-token>
|
|
180
166
|
}
|
|
181
167
|
|
|
182
168
|
:param account_id: the account identification
|
|
183
|
-
:param
|
|
169
|
+
:param account_claims: if provided, may supercede registered account-related claims
|
|
184
170
|
:param logger: optional logger
|
|
185
171
|
:return: the JWT token data, or *None* if error
|
|
186
172
|
:raises InvalidTokenError: token is invalid
|
|
@@ -193,171 +179,121 @@ class JwtData:
|
|
|
193
179
|
:raises InvalidIssuerError: 'iss' claim does not match the expected issuer
|
|
194
180
|
:raises InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
195
181
|
:raises MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
196
|
-
:raises RuntimeError:
|
|
182
|
+
:raises RuntimeError: error accessing the revocation database, or
|
|
197
183
|
the remote JWT provider failed to return a token
|
|
198
184
|
"""
|
|
199
|
-
#
|
|
200
|
-
result: dict[str, Any]
|
|
201
|
-
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
chars=string.ascii_letters + string.digits)
|
|
185
|
+
# initialize the return variable
|
|
186
|
+
result: dict[str, Any] | None = None
|
|
187
|
+
|
|
188
|
+
# process the data in storage
|
|
189
|
+
with (self.access_lock):
|
|
190
|
+
account_data: dict[str, Any] = self.access_data.get(account_id)
|
|
191
|
+
|
|
192
|
+
# was the JWT data obtained ?
|
|
193
|
+
if account_data:
|
|
194
|
+
# yes, proceed
|
|
195
|
+
errors: list[str] = []
|
|
196
|
+
current_claims: dict[str, Any] = account_data.get("claims").copy()
|
|
197
|
+
if account_claims:
|
|
198
|
+
current_claims.update(account_claims)
|
|
199
|
+
|
|
200
|
+
# obtain new tokens
|
|
201
|
+
current_claims["jti"] = str_random(size=32,
|
|
202
|
+
chars=string.ascii_letters + string.digits)
|
|
203
|
+
current_claims["iss"] = account_data.get("reference-url")
|
|
204
|
+
|
|
220
205
|
# where is the JWT service provider ?
|
|
221
|
-
if
|
|
206
|
+
if account_data.get("remote-provider"):
|
|
222
207
|
# JWT service is being provided by a remote server
|
|
223
|
-
errors: list[str] = []
|
|
224
208
|
# Structure of the return data:
|
|
225
209
|
# {
|
|
226
210
|
# "access_token": <jwt-token>,
|
|
227
211
|
# "created_in": <timestamp>,
|
|
228
212
|
# "expires_in": <seconds-to-expiration>,
|
|
213
|
+
# "refresh_token": <jwt-token>
|
|
229
214
|
# ...
|
|
230
215
|
# }
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if
|
|
237
|
-
with self.access_lock:
|
|
238
|
-
control_data["access-token"] = reply.get("access_token")
|
|
239
|
-
reserved_claims["jti"] = token_jti
|
|
240
|
-
reserved_claims["iat"] = reply.get("created_in")
|
|
241
|
-
reserved_claims["exp"] = reply.get("created_in") + reply.get("expires_in")
|
|
242
|
-
else:
|
|
216
|
+
result = _jwt_request_token(errors=errors,
|
|
217
|
+
reference_url=current_claims.get("iss"),
|
|
218
|
+
claims=current_claims,
|
|
219
|
+
timeout=account_data.get("request-timeout"),
|
|
220
|
+
logger=logger)
|
|
221
|
+
if errors:
|
|
243
222
|
raise RuntimeError(" - ".join(errors))
|
|
244
223
|
else:
|
|
245
224
|
# JWT service is being provided locally
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
225
|
+
just_now: int = int(datetime.now(tz=timezone.utc).timestamp())
|
|
226
|
+
current_claims["iat"] = just_now
|
|
227
|
+
|
|
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 account_id = '{account_id}'",
|
|
236
|
+
logger=logger)
|
|
237
|
+
else:
|
|
238
|
+
recs: list[tuple[str]] = db_select(errors=errors,
|
|
239
|
+
sel_stmt=f"SELECT jwt_token FROM {JWT_DB_TABLE} "
|
|
240
|
+
f"WHERE account_id = '{account_id}'",
|
|
241
|
+
max_count=1,
|
|
242
|
+
logger=logger)
|
|
243
|
+
if recs:
|
|
244
|
+
refresh_token = recs[0][0]
|
|
245
|
+
if errors:
|
|
246
|
+
raise RuntimeError(" - ".join(errors))
|
|
247
|
+
|
|
248
|
+
# was it obtained ?
|
|
249
|
+
if not refresh_token:
|
|
250
|
+
# no, issue a new one
|
|
251
|
+
current_claims["exp"] = just_now + account_data.get("refresh-max-age")
|
|
252
|
+
current_claims["nat"] = "R"
|
|
253
|
+
# may raise an exception
|
|
254
|
+
refresh_token: str = jwt.encode(payload=current_claims,
|
|
255
|
+
key=JWT_ENCODING_KEY,
|
|
256
|
+
algorithm=JWT_DEFAULT_ALGORITHM)
|
|
257
|
+
# persist the new refresh token
|
|
258
|
+
if JWT_DB_ENGINE:
|
|
259
|
+
from pypomes_db import db_insert
|
|
260
|
+
db_insert(errors=errors,
|
|
261
|
+
insert_stmt=f"INSERT INTO {JWT_DB_TABLE}",
|
|
262
|
+
insert_data={"account_id": account_id,
|
|
263
|
+
"jwt_token": refresh_token},
|
|
264
|
+
logger=logger)
|
|
265
|
+
if errors:
|
|
266
|
+
raise RuntimeError(" - ".join(errors))
|
|
267
|
+
|
|
268
|
+
# issue the access token
|
|
269
|
+
current_claims["nat"] = "A"
|
|
270
|
+
current_claims["exp"] = just_now + account_data.get("access-max-age")
|
|
254
271
|
# may raise an exception
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
# return the token data
|
|
266
|
-
result = {
|
|
267
|
-
"access_token": control_data.get("access-token"),
|
|
268
|
-
"created_in": reserved_claims.get("iat"),
|
|
269
|
-
"expires_in": reserved_claims.get("exp") - reserved_claims.get("iat")
|
|
270
|
-
}
|
|
271
|
-
else:
|
|
272
|
-
# JWT access data not found
|
|
273
|
-
err_msg: str = f"No JWT access data found for '{account_id}'"
|
|
274
|
-
if logger:
|
|
275
|
-
logger.error(err_msg)
|
|
276
|
-
raise RuntimeError(err_msg)
|
|
277
|
-
|
|
278
|
-
return result
|
|
279
|
-
|
|
280
|
-
def get_token_claims(self,
|
|
281
|
-
token: str,
|
|
282
|
-
logger: Logger = None) -> dict[str, Any]:
|
|
283
|
-
"""
|
|
284
|
-
Obtain and return the claims of a JWT *token*.
|
|
285
|
-
|
|
286
|
-
:param token: the token to be inspected for claims
|
|
287
|
-
:param logger: optional logger
|
|
288
|
-
:return: the token's claimset, or *None* if error
|
|
289
|
-
:raises InvalidTokenError: token is not valid
|
|
290
|
-
:raises ExpiredSignatureError: token has expired
|
|
291
|
-
:raises InvalidAlgorithmError: the specified algorithm is not recognized
|
|
292
|
-
"""
|
|
293
|
-
# declare the return variable
|
|
294
|
-
result: dict[str, Any]
|
|
295
|
-
|
|
296
|
-
if logger:
|
|
297
|
-
logger.debug(msg=f"Retrieve claims for JWT token '{token}'")
|
|
298
|
-
|
|
299
|
-
access_data: dict[str, Any] = self.get_access_data(access_token=token,
|
|
300
|
-
logger=logger)
|
|
301
|
-
if access_data:
|
|
302
|
-
control_data: dict[str, Any] = access_data.get("control-data")
|
|
303
|
-
if control_data.get("remote-provider"):
|
|
304
|
-
# provider is remote
|
|
305
|
-
result = control_data.get("custom-claims")
|
|
272
|
+
access_token: str = jwt.encode(payload=current_claims,
|
|
273
|
+
key=JWT_ENCODING_KEY,
|
|
274
|
+
algorithm=JWT_DEFAULT_ALGORITHM)
|
|
275
|
+
# return the token data
|
|
276
|
+
result = {
|
|
277
|
+
"access_token": access_token,
|
|
278
|
+
"created_in": current_claims.get("iat"),
|
|
279
|
+
"expires_in": current_claims.get("exp"),
|
|
280
|
+
"refresh_token": refresh_token
|
|
281
|
+
}
|
|
306
282
|
else:
|
|
307
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
else:
|
|
313
|
-
raise InvalidTokenError("JWT token is not valid")
|
|
314
|
-
|
|
315
|
-
if logger:
|
|
316
|
-
logger.debug(f"Retrieved claims for JWT token '{token}': {result}")
|
|
317
|
-
|
|
318
|
-
return result
|
|
319
|
-
|
|
320
|
-
def get_access_data(self,
|
|
321
|
-
account_id: str = None,
|
|
322
|
-
access_token: str = None,
|
|
323
|
-
logger: Logger = None) -> dict[str, dict[str, Any]]:
|
|
324
|
-
# noinspection HttpUrlsUsage
|
|
325
|
-
"""
|
|
326
|
-
Retrieve and return the access data in storage for *account_id*, or optionally, for *access_token*.
|
|
327
|
-
|
|
328
|
-
Either *account_id* or *access_token* must be provided, the former having precedence over the later.
|
|
329
|
-
Note that, whereas *account_id* uniquely identifies an access dataset, *access_token* might not,
|
|
330
|
-
and thus, the first dataset associated with it would be returned.
|
|
331
|
-
|
|
332
|
-
:param account_id: the account identification
|
|
333
|
-
:param access_token: the access token
|
|
334
|
-
:param logger: optional logger
|
|
335
|
-
:return: the corresponding item in storage, or *None* if not found
|
|
336
|
-
"""
|
|
337
|
-
# initialize the return variable
|
|
338
|
-
result: dict[str, dict[str, Any]] | None = None
|
|
339
|
-
|
|
340
|
-
if logger:
|
|
341
|
-
target: str = f"account id '{account_id}'" if account_id else f"token '{access_token}'"
|
|
342
|
-
logger.debug(f"Retrieve access data for {target}")
|
|
343
|
-
# retrieve the data
|
|
344
|
-
with self.access_lock:
|
|
345
|
-
for item_data in self.access_data:
|
|
346
|
-
if (account_id and account_id == item_data.get("reserved-claims").get("sub")) or \
|
|
347
|
-
(access_token and access_token == item_data.get("control-data").get("access-token")):
|
|
348
|
-
result = item_data
|
|
349
|
-
break
|
|
350
|
-
if logger:
|
|
351
|
-
logger.debug(f"Data is '{result}'")
|
|
283
|
+
# JWT access data not found
|
|
284
|
+
err_msg: str = f"No JWT access data found for '{account_id}'"
|
|
285
|
+
if logger:
|
|
286
|
+
logger.error(err_msg)
|
|
287
|
+
raise RuntimeError(err_msg)
|
|
352
288
|
|
|
353
289
|
return result
|
|
354
290
|
|
|
355
291
|
|
|
356
|
-
def
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
292
|
+
def _jwt_request_token(errors: list[str],
|
|
293
|
+
reference_url: str,
|
|
294
|
+
claims: dict[str, Any],
|
|
295
|
+
timeout: int = None,
|
|
296
|
+
logger: Logger = None) -> dict[str, Any]:
|
|
361
297
|
"""
|
|
362
298
|
Obtain and return the JWT token from *reference_url*, along with its duration.
|
|
363
299
|
|
|
@@ -403,30 +339,3 @@ def jwt_request_token(errors: list[str],
|
|
|
403
339
|
errors.append(err_msg)
|
|
404
340
|
|
|
405
341
|
return result
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def jwt_validate_token(token: str,
|
|
409
|
-
key: bytes | str,
|
|
410
|
-
algorithm: str,
|
|
411
|
-
logger: Logger = None) -> None:
|
|
412
|
-
"""
|
|
413
|
-
Verify if *token* ia a valid JWT token.
|
|
414
|
-
|
|
415
|
-
Raise an appropriate exception if validation failed.
|
|
416
|
-
|
|
417
|
-
:param token: the token to be validated
|
|
418
|
-
:param key: the secret or public key used to create the token (HS or RSA authentication, respectively)
|
|
419
|
-
:param algorithm: the algorithm used to to sign the token with
|
|
420
|
-
:param logger: optional logger
|
|
421
|
-
:raises InvalidTokenError: token is invalid
|
|
422
|
-
:raises InvalidKeyError: authentication key is not in the proper format
|
|
423
|
-
:raises ExpiredSignatureError: token and refresh period have expired
|
|
424
|
-
:raises InvalidSignatureError: signature does not match the one provided as part of the token
|
|
425
|
-
"""
|
|
426
|
-
if logger:
|
|
427
|
-
logger.debug(msg=f"Validate JWT token '{token}'")
|
|
428
|
-
jwt.decode(jwt=token,
|
|
429
|
-
key=key,
|
|
430
|
-
algorithms=[algorithm])
|
|
431
|
-
if logger:
|
|
432
|
-
logger.debug(msg=f"Token '{token}' is valid")
|
pypomes_jwt/jwt_pomes.py
CHANGED
|
@@ -1,39 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
from
|
|
3
|
-
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
4
|
-
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
|
5
|
-
from datetime import datetime
|
|
6
|
-
from flask import Request, Response, request, jsonify
|
|
1
|
+
import jwt
|
|
2
|
+
from flask import Request, Response, request
|
|
7
3
|
from logging import Logger
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
from
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
JWT_ACCESS_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_ACCESS_MAX_AGE",
|
|
17
|
-
def_value=3600)
|
|
18
|
-
JWT_REFRESH_MAX_AGE: Final[int] = env_get_int(key=f"{APP_PREFIX}_JWT_REFRESH_MAX_AGE",
|
|
19
|
-
def_value=43200)
|
|
20
|
-
JWT_HS_SECRET_KEY: Final[bytes] = env_get_bytes(key=f"{APP_PREFIX}_JWT_HS_SECRET_KEY",
|
|
21
|
-
def_value=token_bytes(nbytes=32))
|
|
22
|
-
|
|
23
|
-
# obtain a RSA private/public key pair
|
|
24
|
-
__priv_bytes: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_RSA_PRIVATE_KEY")
|
|
25
|
-
__pub_bytes: bytes = env_get_bytes(key=f"{APP_PREFIX}_JWT_RSA_PUBLIC_KEY")
|
|
26
|
-
if not __priv_bytes or not __pub_bytes:
|
|
27
|
-
__priv_key: RSAPrivateKey = rsa.generate_private_key(public_exponent=65537,
|
|
28
|
-
key_size=2048)
|
|
29
|
-
__priv_bytes = __priv_key.private_bytes(encoding=serialization.Encoding.PEM,
|
|
30
|
-
format=serialization.PrivateFormat.PKCS8,
|
|
31
|
-
encryption_algorithm=serialization.NoEncryption())
|
|
32
|
-
__pub_key: RSAPublicKey = __priv_key.public_key()
|
|
33
|
-
__pub_bytes = __pub_key.public_bytes(encoding=serialization.Encoding.PEM,
|
|
34
|
-
format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
35
|
-
JWT_RSA_PRIVATE_KEY: Final[bytes] = __priv_bytes
|
|
36
|
-
JWT_RSA_PUBLIC_KEY: Final[bytes] = __pub_bytes
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from .jwt_constants import (
|
|
7
|
+
JWT_ACCESS_MAX_AGE, JWT_REFRESH_MAX_AGE,
|
|
8
|
+
JWT_DEFAULT_ALGORITHM, JWT_DECODING_KEY,
|
|
9
|
+
JWT_DB_ENGINE, JWT_DB_TABLE
|
|
10
|
+
)
|
|
11
|
+
from .jwt_data import JwtData
|
|
37
12
|
|
|
38
13
|
# the JWT data object
|
|
39
14
|
__jwt_data: JwtData = JwtData()
|
|
@@ -58,23 +33,22 @@ def jwt_needed(func: callable) -> callable:
|
|
|
58
33
|
|
|
59
34
|
def jwt_assert_access(account_id: str) -> bool:
|
|
60
35
|
"""
|
|
61
|
-
Determine whether access for *
|
|
36
|
+
Determine whether access for *account_id* has been established.
|
|
62
37
|
|
|
63
38
|
:param account_id: the account identification
|
|
64
39
|
:return: *True* if access data exists for *account_id*, *False* otherwise
|
|
65
40
|
"""
|
|
66
|
-
return __jwt_data.
|
|
41
|
+
return __jwt_data.access_data.get(account_id) is not None
|
|
67
42
|
|
|
68
43
|
|
|
69
44
|
def jwt_set_access(account_id: str,
|
|
70
45
|
reference_url: str,
|
|
71
46
|
claims: dict[str, Any],
|
|
72
|
-
algorithm: Literal["HS256", "HS512", "RSA256", "RSA512"] = JWT_DEFAULT_ALGORITHM,
|
|
73
47
|
access_max_age: int = JWT_ACCESS_MAX_AGE,
|
|
74
48
|
refresh_max_age: int = JWT_REFRESH_MAX_AGE,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
49
|
+
grace_interval: int = None,
|
|
50
|
+
token_audience: str = None,
|
|
51
|
+
token_nonce: str = None,
|
|
78
52
|
request_timeout: int = None,
|
|
79
53
|
remote_provider: bool = True,
|
|
80
54
|
logger: Logger = None) -> None:
|
|
@@ -84,12 +58,11 @@ def jwt_set_access(account_id: str,
|
|
|
84
58
|
:param account_id: the account identification
|
|
85
59
|
:param reference_url: the reference URL (for remote providers, URL to obtain and validate the JWT tokens)
|
|
86
60
|
:param claims: the JWT claimset, as key-value pairs
|
|
87
|
-
:param
|
|
88
|
-
:param
|
|
89
|
-
:param
|
|
90
|
-
:param
|
|
91
|
-
:param
|
|
92
|
-
:param public_key: public key for RSA authentication
|
|
61
|
+
:param access_max_age: access token duration, in seconds
|
|
62
|
+
:param refresh_max_age: refresh token duration, in seconds
|
|
63
|
+
:param grace_interval: optional time to wait for token to be valid, in seconds
|
|
64
|
+
:param token_audience: optional audience the token is intended for
|
|
65
|
+
:param token_nonce: optional value used to associate a client session with a token
|
|
93
66
|
:param request_timeout: timeout for the requests to the reference URL
|
|
94
67
|
:param remote_provider: whether the JWT provider is a remote server
|
|
95
68
|
:param logger: optional logger
|
|
@@ -106,52 +79,144 @@ def jwt_set_access(account_id: str,
|
|
|
106
79
|
reference_url = reference_url[:pos]
|
|
107
80
|
|
|
108
81
|
# register the JWT service
|
|
109
|
-
__jwt_data.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
logger=logger)
|
|
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)
|
|
121
93
|
|
|
122
94
|
|
|
123
95
|
def jwt_remove_access(account_id: str,
|
|
124
|
-
logger: Logger = None) ->
|
|
96
|
+
logger: Logger = None) -> bool:
|
|
125
97
|
"""
|
|
126
98
|
Remove from storage the JWT access data for *account_id*.
|
|
127
99
|
|
|
128
100
|
:param account_id: the account identification
|
|
129
101
|
:param logger: optional logger
|
|
102
|
+
return: *True* if the access data was removed, *False* otherwise
|
|
130
103
|
"""
|
|
131
104
|
if logger:
|
|
132
105
|
logger.debug(msg=f"Remove access data for '{account_id}'")
|
|
133
106
|
|
|
134
|
-
__jwt_data.
|
|
135
|
-
|
|
107
|
+
return __jwt_data.remove_access(account_id=account_id,
|
|
108
|
+
logger=logger)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def jwt_validate_token(errors: list[str] | None,
|
|
112
|
+
token: str,
|
|
113
|
+
nature: Literal["A", "R"] = None,
|
|
114
|
+
logger: Logger = None) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Verify if *token* ia a valid JWT token.
|
|
136
117
|
|
|
118
|
+
Raise an appropriate exception if validation failed.
|
|
137
119
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
120
|
+
:param errors: incidental error messages
|
|
121
|
+
:param token: the token to be validated
|
|
122
|
+
:param nature: optionally validate the token's nature ("A": access token, "R": refresh token)
|
|
123
|
+
:param logger: optional logger
|
|
124
|
+
:return: *True* if token is valid, *False* otherwise
|
|
142
125
|
"""
|
|
143
|
-
|
|
126
|
+
if logger:
|
|
127
|
+
logger.debug(msg=f"Validate JWT token '{token}'")
|
|
128
|
+
|
|
129
|
+
err_msg: str | None = None
|
|
130
|
+
try:
|
|
131
|
+
# raises:
|
|
132
|
+
# InvalidTokenError: token is invalid
|
|
133
|
+
# InvalidKeyError: authentication key is not in the proper format
|
|
134
|
+
# ExpiredSignatureError: token and refresh period have expired
|
|
135
|
+
# InvalidSignatureError: signature does not match the one provided as part of the token
|
|
136
|
+
claims: dict[str, Any] = jwt.decode(jwt=token,
|
|
137
|
+
key=JWT_DECODING_KEY,
|
|
138
|
+
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
139
|
+
if nature and "nat" in claims and nature != claims.get("nat"):
|
|
140
|
+
nat: str = "an access" if nature == "A" else "a refresh"
|
|
141
|
+
err_msg = f"Token is not {nat} token"
|
|
142
|
+
except Exception as e:
|
|
143
|
+
err_msg = str(e)
|
|
144
|
+
|
|
145
|
+
if err_msg:
|
|
146
|
+
if logger:
|
|
147
|
+
logger.error(msg=err_msg)
|
|
148
|
+
if isinstance(errors, list):
|
|
149
|
+
errors.append(err_msg)
|
|
150
|
+
elif logger:
|
|
151
|
+
logger.debug(msg=f"Token '{token}' is valid")
|
|
152
|
+
|
|
153
|
+
return err_msg is None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def jwt_revoke_tokens(errors: list[str] | None,
|
|
157
|
+
account_id: str,
|
|
158
|
+
logger: Logger = None) -> bool:
|
|
159
|
+
"""
|
|
160
|
+
Revoke all refresh tokens associated with *account_id*.
|
|
161
|
+
|
|
162
|
+
Revoke operations require access to a database table defined by *JWT_DB_TABLE*.
|
|
163
|
+
|
|
164
|
+
:param errors: incidental error messages
|
|
165
|
+
:param account_id: the account identification
|
|
166
|
+
:param logger: optional logger
|
|
167
|
+
:return: *True* if operation could be performed, *False* otherwise
|
|
168
|
+
"""
|
|
169
|
+
# initialize the return variable
|
|
170
|
+
result: bool = False
|
|
171
|
+
|
|
172
|
+
if logger:
|
|
173
|
+
logger.debug(msg=f"Revoking refresh tokens of '{account_id}'")
|
|
174
|
+
|
|
175
|
+
op_errors: list[str] = []
|
|
176
|
+
if JWT_DB_ENGINE:
|
|
177
|
+
from pypomes_db import db_delete
|
|
178
|
+
delete_stmt: str = (f"DELETE FROM {JWT_DB_TABLE} "
|
|
179
|
+
f"WHERE account_id = '{account_id}'")
|
|
180
|
+
db_delete(errors=op_errors,
|
|
181
|
+
delete_stmt=delete_stmt,
|
|
182
|
+
logger=logger)
|
|
183
|
+
else:
|
|
184
|
+
op_errors.append("Database access for token revocation has not been specified")
|
|
185
|
+
|
|
186
|
+
if op_errors:
|
|
187
|
+
if logger:
|
|
188
|
+
logger.error(msg="; ".join(op_errors))
|
|
189
|
+
if isinstance(errors, list):
|
|
190
|
+
errors.extend(op_errors)
|
|
191
|
+
else:
|
|
192
|
+
result = True
|
|
193
|
+
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def jwt_get_tokens(errors: list[str] | None,
|
|
198
|
+
account_id: str,
|
|
199
|
+
account_claims: dict[str, Any] = None,
|
|
200
|
+
refresh_token: str = None,
|
|
201
|
+
logger: Logger = None) -> dict[str, Any]:
|
|
202
|
+
"""
|
|
203
|
+
Issue or refresh, and return, the JWT token data associated with *account_id*.
|
|
204
|
+
|
|
205
|
+
If *refresh_token* is provided, its claims are used on issuing the new tokens,
|
|
206
|
+
and claims in *account_claims*, if any, are ignored.
|
|
144
207
|
|
|
145
208
|
Structure of the return data:
|
|
146
209
|
{
|
|
147
210
|
"access_token": <jwt-token>,
|
|
148
211
|
"created_in": <timestamp>,
|
|
149
|
-
"expires_in": <seconds-to-expiration
|
|
212
|
+
"expires_in": <seconds-to-expiration>,
|
|
213
|
+
"refresh_token": <jwt-token>
|
|
150
214
|
}
|
|
151
215
|
|
|
152
216
|
:param errors: incidental error messages
|
|
153
217
|
:param account_id: the account identification
|
|
154
|
-
:param
|
|
218
|
+
:param account_claims: if provided, may supercede registered custom claims
|
|
219
|
+
:param refresh_token: if provided, defines a token refresh operation
|
|
155
220
|
:param logger: optional logger
|
|
156
221
|
:return: the JWT token data, or *None* if error
|
|
157
222
|
"""
|
|
@@ -160,23 +225,35 @@ def jwt_get_token_data(errors: list[str],
|
|
|
160
225
|
|
|
161
226
|
if logger:
|
|
162
227
|
logger.debug(msg=f"Retrieve JWT token data for '{account_id}'")
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if
|
|
168
|
-
|
|
169
|
-
|
|
228
|
+
op_errors: list[str] = []
|
|
229
|
+
if refresh_token:
|
|
230
|
+
account_claims = jwt_get_claims(errors=op_errors,
|
|
231
|
+
token=refresh_token)
|
|
232
|
+
if not op_errors and account_claims.get("nat") != "R":
|
|
233
|
+
op_errors.extend("Invalid parameters")
|
|
234
|
+
|
|
235
|
+
if not op_errors:
|
|
236
|
+
try:
|
|
237
|
+
result = __jwt_data.issue_tokens(account_id=account_id,
|
|
238
|
+
account_claims=account_claims)
|
|
239
|
+
if logger:
|
|
240
|
+
logger.debug(msg=f"Data is '{result}'")
|
|
241
|
+
except Exception as e:
|
|
242
|
+
# token issuing failed
|
|
243
|
+
op_errors.append(str(e))
|
|
244
|
+
|
|
245
|
+
if op_errors:
|
|
170
246
|
if logger:
|
|
171
|
-
logger.error(
|
|
172
|
-
errors
|
|
247
|
+
logger.error("; ".join(op_errors))
|
|
248
|
+
if isinstance(errors, list):
|
|
249
|
+
errors.extend(op_errors)
|
|
173
250
|
|
|
174
251
|
return result
|
|
175
252
|
|
|
176
253
|
|
|
177
|
-
def
|
|
178
|
-
|
|
179
|
-
|
|
254
|
+
def jwt_get_claims(errors: list[str] | None,
|
|
255
|
+
token: str,
|
|
256
|
+
logger: Logger = None) -> dict[str, Any]:
|
|
180
257
|
"""
|
|
181
258
|
Obtain and return the claims set of a JWT *token*.
|
|
182
259
|
|
|
@@ -192,11 +269,19 @@ def jwt_get_token_claims(errors: list[str],
|
|
|
192
269
|
logger.debug(msg=f"Retrieve claims for token '{token}'")
|
|
193
270
|
|
|
194
271
|
try:
|
|
195
|
-
|
|
272
|
+
claims: dict[str, Any] = jwt.decode(jwt=token,
|
|
273
|
+
options={"verify_signature": False})
|
|
274
|
+
if claims.get("nat") in ["A", "R"]:
|
|
275
|
+
result = jwt.decode(jwt=token,
|
|
276
|
+
key=JWT_DECODING_KEY,
|
|
277
|
+
algorithms=[JWT_DEFAULT_ALGORITHM])
|
|
278
|
+
else:
|
|
279
|
+
result = claims
|
|
196
280
|
except Exception as e:
|
|
197
281
|
if logger:
|
|
198
282
|
logger.error(msg=str(e))
|
|
199
|
-
errors
|
|
283
|
+
if isinstance(errors, list):
|
|
284
|
+
errors.append(str(e))
|
|
200
285
|
|
|
201
286
|
return result
|
|
202
287
|
|
|
@@ -222,30 +307,16 @@ def jwt_verify_request(request: Request,
|
|
|
222
307
|
|
|
223
308
|
# was a 'Bearer' authorization obtained ?
|
|
224
309
|
if auth_header and auth_header.startswith("Bearer "):
|
|
225
|
-
# yes, extract and validate the JWT token
|
|
310
|
+
# yes, extract and validate the JWT access token
|
|
226
311
|
token: str = auth_header.split(" ")[1]
|
|
227
312
|
if logger:
|
|
228
313
|
logger.debug(msg=f"Token is '{token}'")
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if datetime.now().timestamp() > access_data.get("reserved-claims").get("exp"):
|
|
236
|
-
err_msg = "Token has expired"
|
|
237
|
-
else:
|
|
238
|
-
# JWT was locally provided
|
|
239
|
-
try:
|
|
240
|
-
jwt_validate_token(token=token,
|
|
241
|
-
key=(control_data.get("hs-secret-key") or
|
|
242
|
-
control_data.get("rsa-public-key")),
|
|
243
|
-
algorithm=control_data.get("algorithm"))
|
|
244
|
-
except Exception as e:
|
|
245
|
-
# validation failed
|
|
246
|
-
err_msg = str(e)
|
|
247
|
-
else:
|
|
248
|
-
err_msg = "No access data found for token"
|
|
314
|
+
errors: list[str] = []
|
|
315
|
+
jwt_validate_token(errors=errors,
|
|
316
|
+
nature="A",
|
|
317
|
+
token=token)
|
|
318
|
+
if errors:
|
|
319
|
+
err_msg = "; ".join(errors)
|
|
249
320
|
else:
|
|
250
321
|
# no 'Bearer' found, report the error
|
|
251
322
|
err_msg = "Request header has no 'Bearer' data"
|
|
@@ -258,101 +329,3 @@ def jwt_verify_request(request: Request,
|
|
|
258
329
|
status=401)
|
|
259
330
|
|
|
260
331
|
return result
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def jwt_claims(token: str = None) -> Response:
|
|
264
|
-
"""
|
|
265
|
-
REST service entry point for retrieving the claims of a JWT token.
|
|
266
|
-
|
|
267
|
-
Structure of the return data:
|
|
268
|
-
{
|
|
269
|
-
"<claim-1>": <value-of-claim-1>,
|
|
270
|
-
...
|
|
271
|
-
"<claim-n>": <value-of-claim-n>
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
:param token: the JWT token
|
|
275
|
-
:return: a *Response* containing the requested JWT token claims, or reporting an error
|
|
276
|
-
"""
|
|
277
|
-
# declare the return variable
|
|
278
|
-
result: Response
|
|
279
|
-
|
|
280
|
-
# retrieve the token
|
|
281
|
-
# noinspection PyUnusedLocal
|
|
282
|
-
if not token:
|
|
283
|
-
token = request.values.get("token")
|
|
284
|
-
if not token:
|
|
285
|
-
with contextlib.suppress(Exception):
|
|
286
|
-
token = request.get_json().get("token")
|
|
287
|
-
|
|
288
|
-
# has the token been obtained ?
|
|
289
|
-
if token:
|
|
290
|
-
# yes, obtain the token data
|
|
291
|
-
try:
|
|
292
|
-
token_claims: dict[str, Any] = __jwt_data.get_token_claims(token=token)
|
|
293
|
-
result = jsonify(token_claims)
|
|
294
|
-
except Exception as e:
|
|
295
|
-
# claims extraction failed
|
|
296
|
-
result = Response(response=str(e),
|
|
297
|
-
status=400)
|
|
298
|
-
else:
|
|
299
|
-
# no, report the problem
|
|
300
|
-
result = Response(response="Invalid parameters",
|
|
301
|
-
status=400)
|
|
302
|
-
|
|
303
|
-
return result
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
def jwt_token(service_params: dict[str, Any] = None) -> Response:
|
|
307
|
-
"""
|
|
308
|
-
REST service entry point for obtaining JWT tokens.
|
|
309
|
-
|
|
310
|
-
The requester must send, as parameter *service_params* or in the body of the request:
|
|
311
|
-
{
|
|
312
|
-
"account-id": "<string>" - required account identification
|
|
313
|
-
"<custom-claim-key-1>": "<custom-claim-value-1>", - optional superceding custom claims
|
|
314
|
-
...
|
|
315
|
-
"<custom-claim-key-n>": "<custom-claim-value-n>"
|
|
316
|
-
}
|
|
317
|
-
If provided, the superceding custom claims will be sent to the remote provider, if applicable
|
|
318
|
-
(custom claims currently registered for the account may be overridden).
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
Structure of the return data:
|
|
322
|
-
{
|
|
323
|
-
"access_token": <jwt-token>,
|
|
324
|
-
"created_in": <timestamp>,
|
|
325
|
-
"expires_in": <seconds-to-expiration>
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
:param service_params: the optional JSON containing the request parameters (defaults to JSON in body)
|
|
329
|
-
:return: a *Response* containing the requested JWT token data, or reporting an error
|
|
330
|
-
"""
|
|
331
|
-
# declare the return variable
|
|
332
|
-
result: Response
|
|
333
|
-
|
|
334
|
-
# retrieve the parameters
|
|
335
|
-
# noinspection PyUnusedLocal
|
|
336
|
-
params: dict[str, Any] = service_params or {}
|
|
337
|
-
if not params:
|
|
338
|
-
with contextlib.suppress(Exception):
|
|
339
|
-
params = request.get_json()
|
|
340
|
-
account_id: str | None = params.pop("account-id", None)
|
|
341
|
-
|
|
342
|
-
# has the account been identified ?
|
|
343
|
-
if account_id:
|
|
344
|
-
# yes, obtain the token data
|
|
345
|
-
try:
|
|
346
|
-
token_data: dict[str, Any] = __jwt_data.get_token_data(account_id=account_id,
|
|
347
|
-
superceding_claims=params)
|
|
348
|
-
result = jsonify(token_data)
|
|
349
|
-
except Exception as e:
|
|
350
|
-
# token validation failed
|
|
351
|
-
result = Response(response=str(e),
|
|
352
|
-
status=401)
|
|
353
|
-
else:
|
|
354
|
-
# no, report the problem
|
|
355
|
-
result = Response(response="Invalid parameters",
|
|
356
|
-
status=401)
|
|
357
|
-
|
|
358
|
-
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_jwt
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
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
|
|
@@ -10,6 +10,6 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Requires-Python: >=3.12
|
|
13
|
-
Requires-Dist: cryptography>=44.0.
|
|
13
|
+
Requires-Dist: cryptography>=44.0.2
|
|
14
14
|
Requires-Dist: pyjwt>=2.10.1
|
|
15
15
|
Requires-Dist: pypomes-core>=1.8.3
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pypomes_jwt/__init__.py,sha256=Op7UEyEDniIkvk65ro8JUPHjGCV_q35k0YL_Ql_DjBc,1010
|
|
2
|
+
pypomes_jwt/jwt_constants.py,sha256=MGuIKc9tFsbWjx3wBlNbdibzytbndK9uhL2kVPW7X2A,4086
|
|
3
|
+
pypomes_jwt/jwt_data.py,sha256=e5KX0slUtrXbbyCtbdjycuqdQwVrvE8BK-_-XhVvHIs,16403
|
|
4
|
+
pypomes_jwt/jwt_pomes.py,sha256=uyhwZzI781yGzWbHKfI-aM9dVXHBB28okQzlvfT4D00,12130
|
|
5
|
+
pypomes_jwt-0.7.2.dist-info/METADATA,sha256=WamGD3ya5b3Ut04oVeFX8TzEeTHRZra6cjpmxJVkeBI,599
|
|
6
|
+
pypomes_jwt-0.7.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
pypomes_jwt-0.7.2.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
|
|
8
|
+
pypomes_jwt-0.7.2.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
pypomes_jwt/__init__.py,sha256=jHyN7gJR575f8djqu7ZsYYxSuhpcy46S4ALAl7SSmHk,940
|
|
2
|
-
pypomes_jwt/jwt_data.py,sha256=U_Bl2F-HAbDOxD-VshE3wD6wXxcWqv6fcaAf1J1pK9Y,20050
|
|
3
|
-
pypomes_jwt/jwt_pomes.py,sha256=ehi_mtFn6LE5QBpPHnLWYDTnuwbz5Cg4tovz0KcZW3k,14140
|
|
4
|
-
pypomes_jwt-0.7.0.dist-info/METADATA,sha256=k8lWDHA8vjQrqa8bi9tep1MRiM6eopzzM5MSTNjbjA4,599
|
|
5
|
-
pypomes_jwt-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
-
pypomes_jwt-0.7.0.dist-info/licenses/LICENSE,sha256=NdakochSXm_H_-DSL_x2JlRCkYikj3snYYvTwgR5d_c,1086
|
|
7
|
-
pypomes_jwt-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|