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.
@@ -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
+ ![Build](https://github.com/jamescrowley321/py-identity-model/workflows/Build/badge.svg)
16
+ ![License](https://img.shields.io/pypi/l/py-identity-model)
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
+ ![Build](https://github.com/jamescrowley321/py-identity-model/workflows/Build/badge.svg)
3
+ ![License](https://img.shields.io/pypi/l/py-identity-model)
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,5 @@
1
+ from .discovery import * # NOQA: F403
2
+ from .exceptions import * # NOQA
3
+ from .jwks import * # NOQA
4
+ from .token_client import * # NOQA
5
+ from .token_validation import * # NOQA
@@ -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,5 @@
1
+ class PyIdentityModelException(Exception):
2
+ """Raised due to an issue with the token verification process"""
3
+
4
+
5
+ __all__ = ["PyIdentityModelException"]
@@ -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"]
@@ -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",)