py-identity-model 0.0.0__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.
- py_identity_model-0.0.0/PKG-INFO +191 -0
- py_identity_model-0.0.0/README.md +178 -0
- py_identity_model-0.0.0/pyproject.toml +84 -0
- py_identity_model-0.0.0/src/py_identity_model/__init__.py +5 -0
- py_identity_model-0.0.0/src/py_identity_model/discovery.py +49 -0
- py_identity_model-0.0.0/src/py_identity_model/exceptions.py +5 -0
- py_identity_model-0.0.0/src/py_identity_model/jwks.py +79 -0
- py_identity_model-0.0.0/src/py_identity_model/token_client.py +50 -0
- py_identity_model-0.0.0/src/py_identity_model/token_validation.py +110 -0
- py_identity_model-0.0.0/src/py_identity_model/validation/__init__.py +0 -0
- py_identity_model-0.0.0/src/py_identity_model/validation/state_validation_result.py +13 -0
- py_identity_model-0.0.0/src/py_identity_model/validation/validation_result.py +20 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: py-identity-model
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: OAuth2.0 and OpenID Connect Client Library
|
|
5
|
+
Author: jamescrowley321
|
|
6
|
+
Author-email: jamescrowley321 <jamescrowley151@gmail.com>
|
|
7
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
8
|
+
Requires-Dist: pyjwt>=2.9.0,<3
|
|
9
|
+
Requires-Dist: requests>=2.32.3,<3
|
|
10
|
+
Requires-Dist: cryptography>=45.0.2,<46
|
|
11
|
+
Requires-Python: ~=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# py-identity-model
|
|
15
|
+

|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
OIDC/OAuth2.0 helper library for decoding JWTs and creating JWTs utilizing the `client_credentials` grant. This project is very limited in functionality, but it has been used in production for years as the foundation of Flask/FastAPI middleware implementations.
|
|
19
|
+
|
|
20
|
+
The use case for the library in its current form is limited to the following
|
|
21
|
+
* Discovery endpoint is utilized
|
|
22
|
+
* JWKS endpoint is utilized
|
|
23
|
+
* Authorization servers with multiple active keys
|
|
24
|
+
|
|
25
|
+
While you can manually construct the validation configs required to manually bypass automated discovery, the library does not currently test those scenarios.
|
|
26
|
+
|
|
27
|
+
For more information on the lower level configuration options for token validation, refer to the official [PyJWT Docs](https://pyjwt.readthedocs.io/en/stable/index.html)
|
|
28
|
+
|
|
29
|
+
Does not currently support opaque tokens.
|
|
30
|
+
|
|
31
|
+
This library inspired by [Duende.IdentityModel](https://github.com/DuendeSoftware/foss/tree/main/identity-model)
|
|
32
|
+
|
|
33
|
+
From Duende.IdentityModel
|
|
34
|
+
> It provides an object model to interact with the endpoints defined in the various OAuth and OpenId Connect specifications in the form of:
|
|
35
|
+
> * types to represent the requests and responses
|
|
36
|
+
> * extension methods to invoke requests
|
|
37
|
+
> * constants defined in the specifications, such as standard scope, claim, and parameter names
|
|
38
|
+
> * other convenience methods for performing common identity related operations
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
This library aims to provide the same features in Python.
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
### Discovery
|
|
45
|
+
|
|
46
|
+
Only a subset of fields is currently mapped.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import os
|
|
50
|
+
|
|
51
|
+
from src.py_identity_model import DiscoveryDocumentRequest, get_discovery_document
|
|
52
|
+
|
|
53
|
+
DISCO_ADDRESS = os.environ["DISCO_ADDRESS"]
|
|
54
|
+
|
|
55
|
+
disco_doc_request = DiscoveryDocumentRequest(address=DISCO_ADDRESS)
|
|
56
|
+
disco_doc_response = get_discovery_document(disco_doc_request)
|
|
57
|
+
print(disco_doc_response)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### JWKs
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import os
|
|
64
|
+
|
|
65
|
+
from src.py_identity_model import (
|
|
66
|
+
DiscoveryDocumentRequest,
|
|
67
|
+
get_discovery_document,
|
|
68
|
+
JwksRequest,
|
|
69
|
+
get_jwks,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
DISCO_ADDRESS = os.environ["DISCO_ADDRESS"]
|
|
73
|
+
|
|
74
|
+
disco_doc_request = DiscoveryDocumentRequest(address=DISCO_ADDRESS)
|
|
75
|
+
disco_doc_response = get_discovery_document(disco_doc_request)
|
|
76
|
+
|
|
77
|
+
jwks_request = JwksRequest(address=disco_doc_response.jwks_uri)
|
|
78
|
+
jwks_response = get_jwks(jwks_request)
|
|
79
|
+
print(jwks_response)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Basic Token Validation
|
|
83
|
+
|
|
84
|
+
Token validation validates the signature of a JWT against the values provided from an OIDC discovery document. The function will throw an exception if the token is expired or signature validation fails.
|
|
85
|
+
|
|
86
|
+
Token validation utilizes [PyJWT](https://github.com/jpadilla/pyjwt) for work related to JWT validation. The configuration object is mapped to the input parameters of `jose.jwt.decode`.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
@dataclass
|
|
90
|
+
class TokenValidationConfig:
|
|
91
|
+
perform_disco: bool
|
|
92
|
+
key: Optional[dict] = None
|
|
93
|
+
audience: Optional[str] = None
|
|
94
|
+
algorithms: Optional[List[str]] = None
|
|
95
|
+
issuer: Optional[str] = None
|
|
96
|
+
subject: Optional[str] = None
|
|
97
|
+
options: Optional[dict] = None
|
|
98
|
+
claims_validator: Optional[Callable] = None
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import os
|
|
103
|
+
|
|
104
|
+
from src.py_identity_model import PyIdentityModelException, validate_token
|
|
105
|
+
|
|
106
|
+
DISCO_ADDRESS = os.environ["DISCO_ADDRESS"]
|
|
107
|
+
|
|
108
|
+
token = get_token() # Get the token in the manner best suited to your application
|
|
109
|
+
|
|
110
|
+
validation_options = {
|
|
111
|
+
"verify_signature": True,
|
|
112
|
+
"verify_aud": True,
|
|
113
|
+
"verify_iat": True,
|
|
114
|
+
"verify_exp": True,
|
|
115
|
+
"verify_nbf": True,
|
|
116
|
+
"verify_iss": True,
|
|
117
|
+
"verify_sub": True,
|
|
118
|
+
"verify_jti": True,
|
|
119
|
+
"verify_at_hash": True,
|
|
120
|
+
"require_aud": False,
|
|
121
|
+
"require_iat": False,
|
|
122
|
+
"require_exp": False,
|
|
123
|
+
"require_nbf": False,
|
|
124
|
+
"require_iss": False,
|
|
125
|
+
"require_sub": False,
|
|
126
|
+
"require_jti": False,
|
|
127
|
+
"require_at_hash": False,
|
|
128
|
+
"leeway": 0,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
validation_config = TokenValidationConfig(
|
|
132
|
+
perform_disco=True,
|
|
133
|
+
audience=TEST_AUDIENCE,
|
|
134
|
+
options=validation_options
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
claims = validate_token(jwt=token, disco_doc_address=DISCO_ADDRESS)
|
|
138
|
+
print(claims)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Token Generation
|
|
142
|
+
|
|
143
|
+
The only current supported flow is the `client_credentials` flow. Load configuration parameters in the method your application supports. Environment variables are used here for demonstration purposes.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import os
|
|
149
|
+
|
|
150
|
+
from src.py_identity_model import (
|
|
151
|
+
ClientCredentialsTokenRequest,
|
|
152
|
+
request_client_credentials_token,
|
|
153
|
+
get_discovery_document,
|
|
154
|
+
DiscoveryDocumentRequest,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
DISCO_ADDRESS = os.environ["DISCO_ADDRESS"]
|
|
158
|
+
CLIENT_ID = os.environ["CLIENT_ID"]
|
|
159
|
+
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
|
|
160
|
+
SCOPE = os.environ["SCOPE"]
|
|
161
|
+
|
|
162
|
+
disco_doc_response = get_discovery_document(
|
|
163
|
+
DiscoveryDocumentRequest(address=DISCO_ADDRESS)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
client_creds_req = ClientCredentialsTokenRequest(
|
|
167
|
+
client_id=CLIENT_ID,
|
|
168
|
+
client_secret=CLIENT_SECRET,
|
|
169
|
+
address=disco_doc_response.token_endpoint,
|
|
170
|
+
scope=SCOPE,
|
|
171
|
+
)
|
|
172
|
+
client_creds_token = request_client_credentials_token(client_creds_req)
|
|
173
|
+
print(client_creds_token)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Roadmap
|
|
177
|
+
These are in no particular order of importance. I am working on this project to bring a library as capable as IdentityModel to the Python ecosystem and will most likely focus on the needful and most used features first.
|
|
178
|
+
* Protocol abstractions and constants
|
|
179
|
+
* Discovery Endpoint
|
|
180
|
+
* Token Endpoint
|
|
181
|
+
* Token Introspection Endpoint
|
|
182
|
+
* Token Revocation Endpoint
|
|
183
|
+
* UserInfo Endpoint
|
|
184
|
+
* Dynamic Client Registration
|
|
185
|
+
* Device Authorization Endpoint
|
|
186
|
+
* Token Validation
|
|
187
|
+
* Example integrations with popular providers
|
|
188
|
+
* Example middleware implementations for Flask and FastApi
|
|
189
|
+
* async Support
|
|
190
|
+
* Setup documentation
|
|
191
|
+
* Opaque tokens
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# py-identity-model
|
|
2
|
+

|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
OIDC/OAuth2.0 helper library for decoding JWTs and creating JWTs utilizing the `client_credentials` grant. This project is very limited in functionality, but it has been used in production for years as the foundation of Flask/FastAPI middleware implementations.
|
|
6
|
+
|
|
7
|
+
The use case for the library in its current form is limited to the following
|
|
8
|
+
* Discovery endpoint is utilized
|
|
9
|
+
* JWKS endpoint is utilized
|
|
10
|
+
* Authorization servers with multiple active keys
|
|
11
|
+
|
|
12
|
+
While you can manually construct the validation configs required to manually bypass automated discovery, the library does not currently test those scenarios.
|
|
13
|
+
|
|
14
|
+
For more information on the lower level configuration options for token validation, refer to the official [PyJWT Docs](https://pyjwt.readthedocs.io/en/stable/index.html)
|
|
15
|
+
|
|
16
|
+
Does not currently support opaque tokens.
|
|
17
|
+
|
|
18
|
+
This library inspired by [Duende.IdentityModel](https://github.com/DuendeSoftware/foss/tree/main/identity-model)
|
|
19
|
+
|
|
20
|
+
From Duende.IdentityModel
|
|
21
|
+
> It provides an object model to interact with the endpoints defined in the various OAuth and OpenId Connect specifications in the form of:
|
|
22
|
+
> * types to represent the requests and responses
|
|
23
|
+
> * extension methods to invoke requests
|
|
24
|
+
> * constants defined in the specifications, such as standard scope, claim, and parameter names
|
|
25
|
+
> * other convenience methods for performing common identity related operations
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
This library aims to provide the same features in Python.
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
### Discovery
|
|
32
|
+
|
|
33
|
+
Only a subset of fields is currently mapped.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
from src.py_identity_model import DiscoveryDocumentRequest, get_discovery_document
|
|
39
|
+
|
|
40
|
+
DISCO_ADDRESS = os.environ["DISCO_ADDRESS"]
|
|
41
|
+
|
|
42
|
+
disco_doc_request = DiscoveryDocumentRequest(address=DISCO_ADDRESS)
|
|
43
|
+
disco_doc_response = get_discovery_document(disco_doc_request)
|
|
44
|
+
print(disco_doc_response)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### JWKs
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import os
|
|
51
|
+
|
|
52
|
+
from src.py_identity_model import (
|
|
53
|
+
DiscoveryDocumentRequest,
|
|
54
|
+
get_discovery_document,
|
|
55
|
+
JwksRequest,
|
|
56
|
+
get_jwks,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
DISCO_ADDRESS = os.environ["DISCO_ADDRESS"]
|
|
60
|
+
|
|
61
|
+
disco_doc_request = DiscoveryDocumentRequest(address=DISCO_ADDRESS)
|
|
62
|
+
disco_doc_response = get_discovery_document(disco_doc_request)
|
|
63
|
+
|
|
64
|
+
jwks_request = JwksRequest(address=disco_doc_response.jwks_uri)
|
|
65
|
+
jwks_response = get_jwks(jwks_request)
|
|
66
|
+
print(jwks_response)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Basic Token Validation
|
|
70
|
+
|
|
71
|
+
Token validation validates the signature of a JWT against the values provided from an OIDC discovery document. The function will throw an exception if the token is expired or signature validation fails.
|
|
72
|
+
|
|
73
|
+
Token validation utilizes [PyJWT](https://github.com/jpadilla/pyjwt) for work related to JWT validation. The configuration object is mapped to the input parameters of `jose.jwt.decode`.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
@dataclass
|
|
77
|
+
class TokenValidationConfig:
|
|
78
|
+
perform_disco: bool
|
|
79
|
+
key: Optional[dict] = None
|
|
80
|
+
audience: Optional[str] = None
|
|
81
|
+
algorithms: Optional[List[str]] = None
|
|
82
|
+
issuer: Optional[str] = None
|
|
83
|
+
subject: Optional[str] = None
|
|
84
|
+
options: Optional[dict] = None
|
|
85
|
+
claims_validator: Optional[Callable] = None
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import os
|
|
90
|
+
|
|
91
|
+
from src.py_identity_model import PyIdentityModelException, validate_token
|
|
92
|
+
|
|
93
|
+
DISCO_ADDRESS = os.environ["DISCO_ADDRESS"]
|
|
94
|
+
|
|
95
|
+
token = get_token() # Get the token in the manner best suited to your application
|
|
96
|
+
|
|
97
|
+
validation_options = {
|
|
98
|
+
"verify_signature": True,
|
|
99
|
+
"verify_aud": True,
|
|
100
|
+
"verify_iat": True,
|
|
101
|
+
"verify_exp": True,
|
|
102
|
+
"verify_nbf": True,
|
|
103
|
+
"verify_iss": True,
|
|
104
|
+
"verify_sub": True,
|
|
105
|
+
"verify_jti": True,
|
|
106
|
+
"verify_at_hash": True,
|
|
107
|
+
"require_aud": False,
|
|
108
|
+
"require_iat": False,
|
|
109
|
+
"require_exp": False,
|
|
110
|
+
"require_nbf": False,
|
|
111
|
+
"require_iss": False,
|
|
112
|
+
"require_sub": False,
|
|
113
|
+
"require_jti": False,
|
|
114
|
+
"require_at_hash": False,
|
|
115
|
+
"leeway": 0,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
validation_config = TokenValidationConfig(
|
|
119
|
+
perform_disco=True,
|
|
120
|
+
audience=TEST_AUDIENCE,
|
|
121
|
+
options=validation_options
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
claims = validate_token(jwt=token, disco_doc_address=DISCO_ADDRESS)
|
|
125
|
+
print(claims)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Token Generation
|
|
129
|
+
|
|
130
|
+
The only current supported flow is the `client_credentials` flow. Load configuration parameters in the method your application supports. Environment variables are used here for demonstration purposes.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
import os
|
|
136
|
+
|
|
137
|
+
from src.py_identity_model import (
|
|
138
|
+
ClientCredentialsTokenRequest,
|
|
139
|
+
request_client_credentials_token,
|
|
140
|
+
get_discovery_document,
|
|
141
|
+
DiscoveryDocumentRequest,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
DISCO_ADDRESS = os.environ["DISCO_ADDRESS"]
|
|
145
|
+
CLIENT_ID = os.environ["CLIENT_ID"]
|
|
146
|
+
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
|
|
147
|
+
SCOPE = os.environ["SCOPE"]
|
|
148
|
+
|
|
149
|
+
disco_doc_response = get_discovery_document(
|
|
150
|
+
DiscoveryDocumentRequest(address=DISCO_ADDRESS)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
client_creds_req = ClientCredentialsTokenRequest(
|
|
154
|
+
client_id=CLIENT_ID,
|
|
155
|
+
client_secret=CLIENT_SECRET,
|
|
156
|
+
address=disco_doc_response.token_endpoint,
|
|
157
|
+
scope=SCOPE,
|
|
158
|
+
)
|
|
159
|
+
client_creds_token = request_client_credentials_token(client_creds_req)
|
|
160
|
+
print(client_creds_token)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Roadmap
|
|
164
|
+
These are in no particular order of importance. I am working on this project to bring a library as capable as IdentityModel to the Python ecosystem and will most likely focus on the needful and most used features first.
|
|
165
|
+
* Protocol abstractions and constants
|
|
166
|
+
* Discovery Endpoint
|
|
167
|
+
* Token Endpoint
|
|
168
|
+
* Token Introspection Endpoint
|
|
169
|
+
* Token Revocation Endpoint
|
|
170
|
+
* UserInfo Endpoint
|
|
171
|
+
* Dynamic Client Registration
|
|
172
|
+
* Device Authorization Endpoint
|
|
173
|
+
* Token Validation
|
|
174
|
+
* Example integrations with popular providers
|
|
175
|
+
* Example middleware implementations for Flask and FastApi
|
|
176
|
+
* async Support
|
|
177
|
+
* Setup documentation
|
|
178
|
+
* Opaque tokens
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py-identity-model"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
description = "OAuth2.0 and OpenID Connect Client Library"
|
|
5
|
+
authors = [{ name = "jamescrowley321", email = "jamescrowley151@gmail.com" }]
|
|
6
|
+
requires-python = "~=3.12"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"License :: OSI Approved :: Apache Software License",
|
|
10
|
+
]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"PyJWT>=2.9.0,<3",
|
|
13
|
+
"requests>=2.32.3,<3",
|
|
14
|
+
"cryptography>=45.0.2,<46",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pre-commit>=3.8.0,<4",
|
|
20
|
+
"python-dotenv>=1.0.1,<2",
|
|
21
|
+
"pytest>=8.3.2,<9",
|
|
22
|
+
"ruff>=0.11.12",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.6,<0.7"]
|
|
27
|
+
build-backend = "uv_build"
|
|
28
|
+
|
|
29
|
+
[tool.semantic_release]
|
|
30
|
+
assets = []
|
|
31
|
+
commit_message = "{version}\n\nAutomatically generated by python-semantic-release"
|
|
32
|
+
commit_parser = "angular"
|
|
33
|
+
logging_use_named_masks = false
|
|
34
|
+
major_on_zero = true
|
|
35
|
+
tag_format = "{version}"
|
|
36
|
+
build_command = "echo 1"
|
|
37
|
+
version_toml = [
|
|
38
|
+
"pyproject.toml:tool.poetry.version",
|
|
39
|
+
]
|
|
40
|
+
commit_version_number = true
|
|
41
|
+
|
|
42
|
+
[tool.semantic_release.branches.main]
|
|
43
|
+
match = "main"
|
|
44
|
+
|
|
45
|
+
[tool.semantic_release.branches.other]
|
|
46
|
+
match = ".*"
|
|
47
|
+
prerelease_token = "rc"
|
|
48
|
+
prerelease = true
|
|
49
|
+
|
|
50
|
+
[tool.semantic_release.changelog]
|
|
51
|
+
changelog_file = "CHANGELOG.md"
|
|
52
|
+
exclude_commit_patterns = []
|
|
53
|
+
|
|
54
|
+
[tool.semantic_release.changelog.environment]
|
|
55
|
+
block_start_string = "{%"
|
|
56
|
+
block_end_string = "%}"
|
|
57
|
+
variable_start_string = "{{"
|
|
58
|
+
variable_end_string = "}}"
|
|
59
|
+
comment_start_string = "{#"
|
|
60
|
+
comment_end_string = "#}"
|
|
61
|
+
trim_blocks = false
|
|
62
|
+
lstrip_blocks = false
|
|
63
|
+
newline_sequence = "\n"
|
|
64
|
+
keep_trailing_newline = false
|
|
65
|
+
extensions = []
|
|
66
|
+
autoescape = true
|
|
67
|
+
|
|
68
|
+
[tool.semantic_release.commit_author]
|
|
69
|
+
env = "GIT_COMMIT_AUTHOR"
|
|
70
|
+
default = "semantic-release <semantic-release>"
|
|
71
|
+
|
|
72
|
+
[tool.semantic_release.commit_parser_options]
|
|
73
|
+
allowed_tags = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "style", "refactor", "test"]
|
|
74
|
+
minor_tags = ["feat"]
|
|
75
|
+
patch_tags = ["fix", "perf"]
|
|
76
|
+
|
|
77
|
+
[tool.semantic_release.remote]
|
|
78
|
+
name = "origin"
|
|
79
|
+
type = "github"
|
|
80
|
+
ignore_token_for_push = false
|
|
81
|
+
|
|
82
|
+
[tool.semantic_release.publish]
|
|
83
|
+
dist_glob_patterns = ["dist/*"]
|
|
84
|
+
upload_to_vcs_release = true
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class DiscoveryDocumentRequest:
|
|
9
|
+
address: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# TODO: full disco doc support
|
|
13
|
+
@dataclass
|
|
14
|
+
class DiscoveryDocumentResponse:
|
|
15
|
+
is_successful: bool
|
|
16
|
+
issuer: Optional[str] = None
|
|
17
|
+
jwks_uri: Optional[str] = None
|
|
18
|
+
authorization_endpoint: Optional[str] = None
|
|
19
|
+
token_endpoint: Optional[str] = None
|
|
20
|
+
error: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_discovery_document(
|
|
24
|
+
disco_doc_req: DiscoveryDocumentRequest,
|
|
25
|
+
) -> DiscoveryDocumentResponse:
|
|
26
|
+
response = requests.get(disco_doc_req.address)
|
|
27
|
+
# TODO: raise for status and handle exceptions
|
|
28
|
+
if response.ok and "application/json" in response.headers.get("Content-Type", ""):
|
|
29
|
+
response_json = response.json()
|
|
30
|
+
return DiscoveryDocumentResponse(
|
|
31
|
+
issuer=response_json["issuer"],
|
|
32
|
+
jwks_uri=response_json["jwks_uri"],
|
|
33
|
+
authorization_endpoint=response_json["authorization_endpoint"],
|
|
34
|
+
token_endpoint=response_json["token_endpoint"],
|
|
35
|
+
is_successful=True,
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
return DiscoveryDocumentResponse(
|
|
39
|
+
is_successful=False,
|
|
40
|
+
error=f"Discovery document request failed with status code: "
|
|
41
|
+
f"{response.status_code}. Response Content: {response.content}",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"DiscoveryDocumentRequest",
|
|
47
|
+
"DiscoveryDocumentResponse",
|
|
48
|
+
"get_discovery_document",
|
|
49
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class JwksRequest:
|
|
9
|
+
address: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class JsonWebKey:
|
|
14
|
+
kty: str
|
|
15
|
+
use: str
|
|
16
|
+
kid: str
|
|
17
|
+
n: str
|
|
18
|
+
e: str
|
|
19
|
+
x5t: str = None
|
|
20
|
+
x5c: List[str] = None
|
|
21
|
+
issuer: Optional[str] = None
|
|
22
|
+
alg: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
def as_dict(self):
|
|
25
|
+
return {
|
|
26
|
+
"kty": self.kty,
|
|
27
|
+
"use": self.use,
|
|
28
|
+
"kid": self.kid,
|
|
29
|
+
"x5t": self.x5t,
|
|
30
|
+
"n": self.n,
|
|
31
|
+
"e": self.e,
|
|
32
|
+
"x5c": self.x5c,
|
|
33
|
+
"issuer": self.issuer,
|
|
34
|
+
"alg": self.alg,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class JwksResponse:
|
|
40
|
+
is_successful: bool
|
|
41
|
+
keys: Optional[List[JsonWebKey]] = None
|
|
42
|
+
error: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def jwks_from_dict(keys_dict: dict) -> JsonWebKey:
|
|
46
|
+
return JsonWebKey(
|
|
47
|
+
kty=keys_dict.get("kty"),
|
|
48
|
+
use=keys_dict.get("use"),
|
|
49
|
+
kid=keys_dict.get("kid"),
|
|
50
|
+
x5c=keys_dict.get("x5c"),
|
|
51
|
+
x5t=keys_dict.get("x5t"),
|
|
52
|
+
n=keys_dict.get("n"),
|
|
53
|
+
e=keys_dict.get("e"),
|
|
54
|
+
issuer=keys_dict.get("issuer"),
|
|
55
|
+
alg=keys_dict.get("alg"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_jwks(jwks_request: JwksRequest) -> JwksResponse:
|
|
60
|
+
try:
|
|
61
|
+
response = requests.get(jwks_request.address)
|
|
62
|
+
if response.ok:
|
|
63
|
+
response_json = response.json()
|
|
64
|
+
keys = [jwks_from_dict(key) for key in response_json["keys"]]
|
|
65
|
+
return JwksResponse(is_successful=True, keys=keys)
|
|
66
|
+
else:
|
|
67
|
+
return JwksResponse(
|
|
68
|
+
is_successful=False,
|
|
69
|
+
error=f"JSON web keys request failed with status code: "
|
|
70
|
+
f"{response.status_code}. Response Content: {response.content}",
|
|
71
|
+
)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return JwksResponse(
|
|
74
|
+
is_successful=False,
|
|
75
|
+
error=f"Unhandled exception during JWKS request: {e}",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["JwksRequest", "JwksResponse", "JsonWebKey", "get_jwks"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ClientCredentialsTokenRequest:
|
|
9
|
+
address: str
|
|
10
|
+
client_id: str
|
|
11
|
+
client_secret: str
|
|
12
|
+
scope: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ClientCredentialsTokenResponse:
|
|
17
|
+
is_successful: bool
|
|
18
|
+
token: Optional[dict] = None
|
|
19
|
+
error: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def request_client_credentials_token(
|
|
23
|
+
request: ClientCredentialsTokenRequest,
|
|
24
|
+
) -> ClientCredentialsTokenResponse:
|
|
25
|
+
params = {"grant_type": "client_credentials", "scope": request.scope}
|
|
26
|
+
|
|
27
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
28
|
+
|
|
29
|
+
response = requests.post(
|
|
30
|
+
request.address,
|
|
31
|
+
data=params,
|
|
32
|
+
headers=headers,
|
|
33
|
+
auth=(request.client_id, request.client_secret),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if response.ok:
|
|
37
|
+
return ClientCredentialsTokenResponse(is_successful=True, token=response.json())
|
|
38
|
+
else:
|
|
39
|
+
return ClientCredentialsTokenResponse(
|
|
40
|
+
is_successful=False,
|
|
41
|
+
error=f"Token generation request failed with status code: "
|
|
42
|
+
f"{response.status_code}. Response Content: {response.content}",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ClientCredentialsTokenRequest",
|
|
48
|
+
"ClientCredentialsTokenResponse",
|
|
49
|
+
"request_client_credentials_token",
|
|
50
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from typing import List, Optional, Callable
|
|
4
|
+
|
|
5
|
+
from jwt import PyJWK, get_unverified_header, decode
|
|
6
|
+
|
|
7
|
+
from .discovery import (
|
|
8
|
+
get_discovery_document,
|
|
9
|
+
DiscoveryDocumentRequest,
|
|
10
|
+
DiscoveryDocumentResponse,
|
|
11
|
+
)
|
|
12
|
+
from .exceptions import PyIdentityModelException
|
|
13
|
+
from .jwks import get_jwks, JwksRequest, JsonWebKey, JwksResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TokenValidationConfig:
|
|
18
|
+
perform_disco: bool
|
|
19
|
+
key: Optional[dict] = None
|
|
20
|
+
audience: Optional[str] = None
|
|
21
|
+
algorithms: Optional[List[str]] = None
|
|
22
|
+
issuer: Optional[str] = None
|
|
23
|
+
subject: Optional[str] = None
|
|
24
|
+
options: Optional[dict] = None
|
|
25
|
+
claims_validator: Optional[Callable] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_public_key_from_jwk(jwt: str, keys: List[JsonWebKey]) -> JsonWebKey:
|
|
29
|
+
# TODO: clean up flow to prevent multiple decodes
|
|
30
|
+
headers = get_unverified_header(jwt)
|
|
31
|
+
filtered_keys = list(filter(lambda x: x.kid == headers.get("kid", None), keys))
|
|
32
|
+
if not filtered_keys:
|
|
33
|
+
raise PyIdentityModelException("No matching kid found")
|
|
34
|
+
|
|
35
|
+
key = filtered_keys[0]
|
|
36
|
+
if not key.alg:
|
|
37
|
+
key.alg = headers["alg"]
|
|
38
|
+
|
|
39
|
+
return key
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_token_config(
|
|
43
|
+
token_validation_config: TokenValidationConfig,
|
|
44
|
+
) -> bool:
|
|
45
|
+
if token_validation_config.perform_disco:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
if not token_validation_config.key and not token_validation_config.algorithms:
|
|
49
|
+
raise PyIdentityModelException(
|
|
50
|
+
"TokenValidationConfig.key and TokenValidationConfig.algorithms are required if perform_disco is False"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@lru_cache
|
|
55
|
+
def _get_disco_response(disco_doc_address: str) -> DiscoveryDocumentResponse:
|
|
56
|
+
return get_discovery_document(DiscoveryDocumentRequest(address=disco_doc_address))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@lru_cache
|
|
60
|
+
def _get_jwks_response(jwks_uri: str) -> JwksResponse:
|
|
61
|
+
return get_jwks(JwksRequest(address=jwks_uri))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_token(
|
|
65
|
+
jwt: str,
|
|
66
|
+
token_validation_config: TokenValidationConfig,
|
|
67
|
+
disco_doc_address: str = None,
|
|
68
|
+
) -> dict:
|
|
69
|
+
_validate_token_config(token_validation_config)
|
|
70
|
+
|
|
71
|
+
if token_validation_config.perform_disco:
|
|
72
|
+
disco_doc_response = _get_disco_response(disco_doc_address)
|
|
73
|
+
|
|
74
|
+
if not disco_doc_response.is_successful:
|
|
75
|
+
raise PyIdentityModelException(disco_doc_response.error)
|
|
76
|
+
|
|
77
|
+
jwks_response = _get_jwks_response(disco_doc_response.jwks_uri)
|
|
78
|
+
if not jwks_response.is_successful:
|
|
79
|
+
raise PyIdentityModelException(jwks_response.error)
|
|
80
|
+
|
|
81
|
+
token_validation_config.key = _get_public_key_from_jwk(
|
|
82
|
+
jwt, jwks_response.keys
|
|
83
|
+
).as_dict()
|
|
84
|
+
token_validation_config.algorithms = token_validation_config.key["alg"]
|
|
85
|
+
|
|
86
|
+
decoded_token = decode(
|
|
87
|
+
jwt,
|
|
88
|
+
PyJWK(token_validation_config.key, token_validation_config.algorithms),
|
|
89
|
+
audience=token_validation_config.audience,
|
|
90
|
+
algorithms=token_validation_config.algorithms,
|
|
91
|
+
issuer=disco_doc_response.issuer,
|
|
92
|
+
options=token_validation_config.options,
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
decoded_token = decode(
|
|
96
|
+
jwt,
|
|
97
|
+
PyJWK(token_validation_config.key, token_validation_config.algorithms),
|
|
98
|
+
audience=token_validation_config.audience,
|
|
99
|
+
algorithms=token_validation_config.algorithms,
|
|
100
|
+
issuer=token_validation_config.issuer,
|
|
101
|
+
options=token_validation_config.options,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if token_validation_config.claims_validator:
|
|
105
|
+
token_validation_config.claims_validator(decoded_token)
|
|
106
|
+
|
|
107
|
+
return decoded_token
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["validate_token", "TokenValidationConfig"]
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from .validation_result import ValidationResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class StateValidationResult:
|
|
9
|
+
access_token: str = ""
|
|
10
|
+
id_token: str = ""
|
|
11
|
+
auth_response_is_valid: bool = False
|
|
12
|
+
state: ValidationResult = ValidationResult.NotSet
|
|
13
|
+
decoded_id_token: Optional[dict] = None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ValidationResult(Enum):
|
|
5
|
+
NotSet = ("NotSet",)
|
|
6
|
+
StatesDoNotMatch = ("StatesDoNotMatch",)
|
|
7
|
+
SignatureFailed = ("SignatureFailed",)
|
|
8
|
+
IncorrectNonce = ("IncorrectNonce",)
|
|
9
|
+
RequiredPropertyMissing = ("RequiredPropertyMissing",)
|
|
10
|
+
MaxOffsetExpired = ("MaxOffsetExpired",)
|
|
11
|
+
IssDoesNotMatchIssuer = ("IssDoesNotMatchIssuer",)
|
|
12
|
+
NoAuthWellKnownEndPoints = ("NoAuthWellKnownEndPoints",)
|
|
13
|
+
IncorrectAud = ("IncorrectAud",)
|
|
14
|
+
IncorrectIdTokenClaimsAfterRefresh = ("IncorrectIdTokenClaimsAfterRefresh",)
|
|
15
|
+
IncorrectAzp = ("IncorrectAzp",)
|
|
16
|
+
TokenExpired = ("TokenExpired",)
|
|
17
|
+
IncorrectAtHash = ("IncorrectAtHash",)
|
|
18
|
+
Ok = ("Ok",)
|
|
19
|
+
LoginRequired = ("LoginRequired",)
|
|
20
|
+
SecureTokenServerError = ("SecureTokenServerError",)
|