jwks-client 0.1.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.
- jwks_client-0.1.0/PKG-INFO +220 -0
- jwks_client-0.1.0/README.md +185 -0
- jwks_client-0.1.0/jwks_client/__init__.py +14 -0
- jwks_client-0.1.0/jwks_client/client.py +221 -0
- jwks_client-0.1.0/jwks_client/errors.py +13 -0
- jwks_client-0.1.0/jwks_client/fastapi.py +56 -0
- jwks_client-0.1.0/jwks_client/litestar.py +73 -0
- jwks_client-0.1.0/jwks_client/py.typed +0 -0
- jwks_client-0.1.0/jwks_client/testing.py +56 -0
- jwks_client-0.1.0/jwks_client/verifier.py +57 -0
- jwks_client-0.1.0/pyproject.toml +120 -0
- jwks_client-0.1.0/pyproject.toml.orig +105 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jwks-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async JWKS client for verifying JWTs, with key caching, resilient fetching, and Litestar and FastAPI integrations
|
|
5
|
+
Keywords: jwks,jwt,jwk,oidc,oauth2,authentication,asyncio,litestar,fastapi,python
|
|
6
|
+
Author: Artur Shiriev
|
|
7
|
+
Author-email: Artur Shiriev <me@shiriev.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Framework :: AsyncIO
|
|
20
|
+
Classifier: Framework :: FastAPI
|
|
21
|
+
Classifier: Framework :: Litestar
|
|
22
|
+
Requires-Dist: pyjwt[crypto]>=2.15.1
|
|
23
|
+
Requires-Dist: httpware>=0.15.0,<1
|
|
24
|
+
Requires-Dist: httpx2>=2.0.0,<3
|
|
25
|
+
Requires-Dist: fastapi>=0.115 ; extra == 'fastapi'
|
|
26
|
+
Requires-Dist: litestar>=2.19 ; extra == 'litestar'
|
|
27
|
+
Requires-Python: >=3.11, <4
|
|
28
|
+
Project-URL: Homepage, https://modern-python.org
|
|
29
|
+
Project-URL: Repository, https://github.com/modern-python/jwks-client
|
|
30
|
+
Project-URL: Issues, https://github.com/modern-python/jwks-client/issues
|
|
31
|
+
Project-URL: Changelog, https://github.com/modern-python/jwks-client/releases
|
|
32
|
+
Provides-Extra: fastapi
|
|
33
|
+
Provides-Extra: litestar
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# jwks-client
|
|
37
|
+
|
|
38
|
+
An async JWKS client for verifying JWTs, with key caching, resilient fetching over
|
|
39
|
+
[httpware](https://github.com/modern-python/httpware), and Litestar and FastAPI integrations. It
|
|
40
|
+
fetches an identity provider's JSON Web Key Set, caches it, refetches on key rotation without
|
|
41
|
+
letting unknown `kid`s flood the provider, keeps serving the last good keys through an outage, and
|
|
42
|
+
verifies tokens with [PyJWT](https://github.com/jpadilla/pyjwt).
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from jwks_client import JWKSClient, JWTVerifier
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
jwks = JWKSClient("https://idp.example.com/.well-known/jwks.json")
|
|
49
|
+
verifier = JWTVerifier(jwks, algorithms=["RS256"], audience="my-api", issuer="https://idp.example.com")
|
|
50
|
+
|
|
51
|
+
claims = await verifier.verify(token)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install jwks-client # core
|
|
58
|
+
pip install jwks-client[litestar] # + Litestar middleware
|
|
59
|
+
pip install jwks-client[fastapi] # + FastAPI dependency
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Verifying tokens
|
|
63
|
+
|
|
64
|
+
`JWTVerifier` fixes the policy once (`algorithms`, `audience`, `issuer`, `leeway`, `options`), so no
|
|
65
|
+
call site can forget a check. `verify()` checks the token's `alg` against `algorithms` before any
|
|
66
|
+
network call, finds the key by the token's `kid`, verifies the signature, then validates `exp`,
|
|
67
|
+
`nbf`, `aud`, and `iss` through `jwt.decode`. When `audience` or `issuer` is set, a token without
|
|
68
|
+
that claim is rejected.
|
|
69
|
+
|
|
70
|
+
For a key published without an `alg`, the key is bound to the token's `alg`, provided it is in
|
|
71
|
+
`algorithms` and fits the key type, so an RSA key can verify `PS256` as well as `RS256`.
|
|
72
|
+
|
|
73
|
+
The same check is available per call as `JWKSClient.decode(token, algorithms=..., ...)`. To verify
|
|
74
|
+
yourself, take the key and call PyJWT directly:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import jwt
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
key = await jwks.get_signing_key_from_jwt(token) # or get_signing_key(kid)
|
|
81
|
+
claims = jwt.decode(token, key, algorithms=["RS256"], audience="my-api")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Errors
|
|
85
|
+
|
|
86
|
+
| Raised | When | Integrations answer |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `jwt.InvalidTokenError` subclasses | The token is malformed, expired, badly signed, or its claims fail | 401 |
|
|
89
|
+
| `KeyNotFoundError` (also a `jwt.InvalidTokenError`) | No key in the set matches the token's `kid` | 401 |
|
|
90
|
+
| `JWKSFetchError` | The JWKS endpoint failed and no usable cached keys remain; `__cause__` holds the httpware error | 503 |
|
|
91
|
+
|
|
92
|
+
All of the package's own errors inherit `JWKSError`.
|
|
93
|
+
|
|
94
|
+
## Litestar
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import litestar
|
|
98
|
+
from litestar.middleware import DefineMiddleware
|
|
99
|
+
from litestar.openapi.config import OpenAPIConfig
|
|
100
|
+
|
|
101
|
+
from jwks_client.litestar import JWKSAuthMiddleware, create_openapi_security_config
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
app = litestar.Litestar(
|
|
105
|
+
route_handlers=[...],
|
|
106
|
+
middleware=[
|
|
107
|
+
DefineMiddleware(
|
|
108
|
+
JWKSAuthMiddleware,
|
|
109
|
+
verifier=verifier,
|
|
110
|
+
user_parser=lambda claims: User.model_validate(claims), # optional
|
|
111
|
+
exclude=["/health", "/docs"],
|
|
112
|
+
),
|
|
113
|
+
],
|
|
114
|
+
openapi_config=OpenAPIConfig(title="my-api", version="1", **create_openapi_security_config()),
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`request.auth` holds the verified claims. `request.user` holds `user_parser(claims)`, or the claims
|
|
119
|
+
when no parser is given; a parser returning `None` rejects the request with 401. `exclude`,
|
|
120
|
+
`exclude_from_auth_key`, `exclude_http_methods`, and `scopes` are Litestar's own
|
|
121
|
+
`AbstractAuthenticationMiddleware` options.
|
|
122
|
+
|
|
123
|
+
## FastAPI
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import typing
|
|
127
|
+
|
|
128
|
+
import fastapi
|
|
129
|
+
|
|
130
|
+
from jwks_client.fastapi import JWKSBearer
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
bearer = JWKSBearer(verifier, user_parser=lambda claims: User.model_validate(claims))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.get("/me")
|
|
137
|
+
async def me(user: typing.Annotated[User, fastapi.Security(bearer)]) -> User:
|
|
138
|
+
return user
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The dependency returns `user_parser(claims)`, or the claims when no parser is given, and adds a
|
|
142
|
+
`Bearer` security scheme to the OpenAPI schema.
|
|
143
|
+
|
|
144
|
+
## OpenAPI and OpenID Connect
|
|
145
|
+
|
|
146
|
+
Both integrations declare an HTTP `Bearer` scheme. Pass `openid_configuration_url`, the provider's
|
|
147
|
+
discovery document at `/.well-known/openid-configuration` rather than its JWKS URL, to declare an
|
|
148
|
+
`OpenIdConnect` scheme as well (Litestar) or instead (FastAPI).
|
|
149
|
+
|
|
150
|
+
## Resolving the verifier lazily
|
|
151
|
+
|
|
152
|
+
Both integrations take a `TokenVerifier`: anything with an async `verify(token)`. A verifier that
|
|
153
|
+
lives in a DI container can be resolved on each call by a small wrapper:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
class ContainerVerifier:
|
|
157
|
+
async def verify(self, token: str) -> dict[str, typing.Any]:
|
|
158
|
+
return await container.resolve(JWTVerifier).verify(token)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
DefineMiddleware(JWKSAuthMiddleware, verifier=ContainerVerifier())
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The container must hand back the same verifier every time: a new `JWKSClient` per request starts
|
|
165
|
+
with no keys and fetches the key set on every request.
|
|
166
|
+
|
|
167
|
+
## Caching
|
|
168
|
+
|
|
169
|
+
| Parameter | Default | Effect |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `ttl` | `300` s | How long a fetched key set is used before it is refetched. |
|
|
172
|
+
| `refetch_cooldown` | `30` s | Minimum time between fetches triggered by an unknown `kid`, and between retries after a failed fetch. |
|
|
173
|
+
| `stale_if_error` | `3600` s | How long past `ttl` the last good key set keeps being served while refetches fail. |
|
|
174
|
+
|
|
175
|
+
Concurrent callers share one fetch. Call `await jwks.refresh()` at startup to fetch keys before the
|
|
176
|
+
first request.
|
|
177
|
+
|
|
178
|
+
## HTTP client
|
|
179
|
+
|
|
180
|
+
Without `http_client=`, the client builds and owns an `httpware.AsyncClient` with a 10 s timeout, a
|
|
181
|
+
1 MiB response cap, a circuit breaker, and retries, and closes it in `aclose()` (or on leaving
|
|
182
|
+
`async with`). Pass your own `httpware.AsyncClient` to choose the middleware; the client never
|
|
183
|
+
closes one it did not create:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
import httpware
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
http_client = httpware.AsyncClient(
|
|
190
|
+
timeout=5.0,
|
|
191
|
+
max_response_body_bytes=1024 * 1024,
|
|
192
|
+
middleware=[httpware.AsyncCircuitBreaker(failure_threshold=3), httpware.AsyncRetry(max_attempts=2)],
|
|
193
|
+
)
|
|
194
|
+
jwks = JWKSClient("https://idp.example.com/.well-known/jwks.json", http_client=http_client)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A `JWKSClient` belongs to one event loop. Create one per process and share it across requests.
|
|
198
|
+
|
|
199
|
+
## Testing
|
|
200
|
+
|
|
201
|
+
`jwks_client.testing` signs real tokens, so tests exercise the same verification as production:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from jwks_client.testing import FakeIdentityProvider
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
idp = FakeIdentityProvider()
|
|
208
|
+
verifier = JWTVerifier(idp.create_jwks_client(), algorithms=["RS256"], audience="my-api", issuer=idp.issuer)
|
|
209
|
+
token = idp.issue_token({"sub": "user-1", "aud": "my-api"})
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`StaticTokenVerifier({"dev-token": {"sub": "developer"}})` accepts fixed tokens without any
|
|
213
|
+
signature, for local development only.
|
|
214
|
+
|
|
215
|
+
## 📦 [PyPI](https://pypi.org/project/jwks-client) · 📝 [License](https://github.com/modern-python/jwks-client/blob/main/LICENSE)
|
|
216
|
+
|
|
217
|
+
## Part of `modern-python`
|
|
218
|
+
|
|
219
|
+
Browse the full list of templates and libraries in
|
|
220
|
+
[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# jwks-client
|
|
2
|
+
|
|
3
|
+
An async JWKS client for verifying JWTs, with key caching, resilient fetching over
|
|
4
|
+
[httpware](https://github.com/modern-python/httpware), and Litestar and FastAPI integrations. It
|
|
5
|
+
fetches an identity provider's JSON Web Key Set, caches it, refetches on key rotation without
|
|
6
|
+
letting unknown `kid`s flood the provider, keeps serving the last good keys through an outage, and
|
|
7
|
+
verifies tokens with [PyJWT](https://github.com/jpadilla/pyjwt).
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from jwks_client import JWKSClient, JWTVerifier
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
jwks = JWKSClient("https://idp.example.com/.well-known/jwks.json")
|
|
14
|
+
verifier = JWTVerifier(jwks, algorithms=["RS256"], audience="my-api", issuer="https://idp.example.com")
|
|
15
|
+
|
|
16
|
+
claims = await verifier.verify(token)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install jwks-client # core
|
|
23
|
+
pip install jwks-client[litestar] # + Litestar middleware
|
|
24
|
+
pip install jwks-client[fastapi] # + FastAPI dependency
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Verifying tokens
|
|
28
|
+
|
|
29
|
+
`JWTVerifier` fixes the policy once (`algorithms`, `audience`, `issuer`, `leeway`, `options`), so no
|
|
30
|
+
call site can forget a check. `verify()` checks the token's `alg` against `algorithms` before any
|
|
31
|
+
network call, finds the key by the token's `kid`, verifies the signature, then validates `exp`,
|
|
32
|
+
`nbf`, `aud`, and `iss` through `jwt.decode`. When `audience` or `issuer` is set, a token without
|
|
33
|
+
that claim is rejected.
|
|
34
|
+
|
|
35
|
+
For a key published without an `alg`, the key is bound to the token's `alg`, provided it is in
|
|
36
|
+
`algorithms` and fits the key type, so an RSA key can verify `PS256` as well as `RS256`.
|
|
37
|
+
|
|
38
|
+
The same check is available per call as `JWKSClient.decode(token, algorithms=..., ...)`. To verify
|
|
39
|
+
yourself, take the key and call PyJWT directly:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import jwt
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
key = await jwks.get_signing_key_from_jwt(token) # or get_signing_key(kid)
|
|
46
|
+
claims = jwt.decode(token, key, algorithms=["RS256"], audience="my-api")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Errors
|
|
50
|
+
|
|
51
|
+
| Raised | When | Integrations answer |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `jwt.InvalidTokenError` subclasses | The token is malformed, expired, badly signed, or its claims fail | 401 |
|
|
54
|
+
| `KeyNotFoundError` (also a `jwt.InvalidTokenError`) | No key in the set matches the token's `kid` | 401 |
|
|
55
|
+
| `JWKSFetchError` | The JWKS endpoint failed and no usable cached keys remain; `__cause__` holds the httpware error | 503 |
|
|
56
|
+
|
|
57
|
+
All of the package's own errors inherit `JWKSError`.
|
|
58
|
+
|
|
59
|
+
## Litestar
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import litestar
|
|
63
|
+
from litestar.middleware import DefineMiddleware
|
|
64
|
+
from litestar.openapi.config import OpenAPIConfig
|
|
65
|
+
|
|
66
|
+
from jwks_client.litestar import JWKSAuthMiddleware, create_openapi_security_config
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
app = litestar.Litestar(
|
|
70
|
+
route_handlers=[...],
|
|
71
|
+
middleware=[
|
|
72
|
+
DefineMiddleware(
|
|
73
|
+
JWKSAuthMiddleware,
|
|
74
|
+
verifier=verifier,
|
|
75
|
+
user_parser=lambda claims: User.model_validate(claims), # optional
|
|
76
|
+
exclude=["/health", "/docs"],
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
openapi_config=OpenAPIConfig(title="my-api", version="1", **create_openapi_security_config()),
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`request.auth` holds the verified claims. `request.user` holds `user_parser(claims)`, or the claims
|
|
84
|
+
when no parser is given; a parser returning `None` rejects the request with 401. `exclude`,
|
|
85
|
+
`exclude_from_auth_key`, `exclude_http_methods`, and `scopes` are Litestar's own
|
|
86
|
+
`AbstractAuthenticationMiddleware` options.
|
|
87
|
+
|
|
88
|
+
## FastAPI
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import typing
|
|
92
|
+
|
|
93
|
+
import fastapi
|
|
94
|
+
|
|
95
|
+
from jwks_client.fastapi import JWKSBearer
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
bearer = JWKSBearer(verifier, user_parser=lambda claims: User.model_validate(claims))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.get("/me")
|
|
102
|
+
async def me(user: typing.Annotated[User, fastapi.Security(bearer)]) -> User:
|
|
103
|
+
return user
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The dependency returns `user_parser(claims)`, or the claims when no parser is given, and adds a
|
|
107
|
+
`Bearer` security scheme to the OpenAPI schema.
|
|
108
|
+
|
|
109
|
+
## OpenAPI and OpenID Connect
|
|
110
|
+
|
|
111
|
+
Both integrations declare an HTTP `Bearer` scheme. Pass `openid_configuration_url`, the provider's
|
|
112
|
+
discovery document at `/.well-known/openid-configuration` rather than its JWKS URL, to declare an
|
|
113
|
+
`OpenIdConnect` scheme as well (Litestar) or instead (FastAPI).
|
|
114
|
+
|
|
115
|
+
## Resolving the verifier lazily
|
|
116
|
+
|
|
117
|
+
Both integrations take a `TokenVerifier`: anything with an async `verify(token)`. A verifier that
|
|
118
|
+
lives in a DI container can be resolved on each call by a small wrapper:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
class ContainerVerifier:
|
|
122
|
+
async def verify(self, token: str) -> dict[str, typing.Any]:
|
|
123
|
+
return await container.resolve(JWTVerifier).verify(token)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
DefineMiddleware(JWKSAuthMiddleware, verifier=ContainerVerifier())
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The container must hand back the same verifier every time: a new `JWKSClient` per request starts
|
|
130
|
+
with no keys and fetches the key set on every request.
|
|
131
|
+
|
|
132
|
+
## Caching
|
|
133
|
+
|
|
134
|
+
| Parameter | Default | Effect |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `ttl` | `300` s | How long a fetched key set is used before it is refetched. |
|
|
137
|
+
| `refetch_cooldown` | `30` s | Minimum time between fetches triggered by an unknown `kid`, and between retries after a failed fetch. |
|
|
138
|
+
| `stale_if_error` | `3600` s | How long past `ttl` the last good key set keeps being served while refetches fail. |
|
|
139
|
+
|
|
140
|
+
Concurrent callers share one fetch. Call `await jwks.refresh()` at startup to fetch keys before the
|
|
141
|
+
first request.
|
|
142
|
+
|
|
143
|
+
## HTTP client
|
|
144
|
+
|
|
145
|
+
Without `http_client=`, the client builds and owns an `httpware.AsyncClient` with a 10 s timeout, a
|
|
146
|
+
1 MiB response cap, a circuit breaker, and retries, and closes it in `aclose()` (or on leaving
|
|
147
|
+
`async with`). Pass your own `httpware.AsyncClient` to choose the middleware; the client never
|
|
148
|
+
closes one it did not create:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
import httpware
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
http_client = httpware.AsyncClient(
|
|
155
|
+
timeout=5.0,
|
|
156
|
+
max_response_body_bytes=1024 * 1024,
|
|
157
|
+
middleware=[httpware.AsyncCircuitBreaker(failure_threshold=3), httpware.AsyncRetry(max_attempts=2)],
|
|
158
|
+
)
|
|
159
|
+
jwks = JWKSClient("https://idp.example.com/.well-known/jwks.json", http_client=http_client)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A `JWKSClient` belongs to one event loop. Create one per process and share it across requests.
|
|
163
|
+
|
|
164
|
+
## Testing
|
|
165
|
+
|
|
166
|
+
`jwks_client.testing` signs real tokens, so tests exercise the same verification as production:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from jwks_client.testing import FakeIdentityProvider
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
idp = FakeIdentityProvider()
|
|
173
|
+
verifier = JWTVerifier(idp.create_jwks_client(), algorithms=["RS256"], audience="my-api", issuer=idp.issuer)
|
|
174
|
+
token = idp.issue_token({"sub": "user-1", "aud": "my-api"})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`StaticTokenVerifier({"dev-token": {"sub": "developer"}})` accepts fixed tokens without any
|
|
178
|
+
signature, for local development only.
|
|
179
|
+
|
|
180
|
+
## 📦 [PyPI](https://pypi.org/project/jwks-client) · 📝 [License](https://github.com/modern-python/jwks-client/blob/main/LICENSE)
|
|
181
|
+
|
|
182
|
+
## Part of `modern-python`
|
|
183
|
+
|
|
184
|
+
Browse the full list of templates and libraries in
|
|
185
|
+
[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from jwks_client.client import JWKSClient
|
|
2
|
+
from jwks_client.errors import JWKSError, JWKSFetchError, KeyNotFoundError
|
|
3
|
+
from jwks_client.verifier import JWTVerifier, TokenVerifier, extract_bearer_token
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"JWKSClient",
|
|
8
|
+
"JWKSError",
|
|
9
|
+
"JWKSFetchError",
|
|
10
|
+
"JWTVerifier",
|
|
11
|
+
"KeyNotFoundError",
|
|
12
|
+
"TokenVerifier",
|
|
13
|
+
"extract_bearer_token",
|
|
14
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import dataclasses
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
import typing
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from collections.abc import Callable, Container, Iterable, Sequence
|
|
10
|
+
|
|
11
|
+
import httpware
|
|
12
|
+
import jwt
|
|
13
|
+
from jwt.types import Options
|
|
14
|
+
|
|
15
|
+
from jwks_client.errors import JWKSFetchError, KeyNotFoundError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
LOGGER: typing.Final = logging.getLogger("jwks_client")
|
|
19
|
+
DEFAULT_TTL: typing.Final = 300.0
|
|
20
|
+
DEFAULT_REFETCH_COOLDOWN: typing.Final = 30.0
|
|
21
|
+
DEFAULT_STALE_IF_ERROR: typing.Final = 3600.0
|
|
22
|
+
DEFAULT_TIMEOUT: typing.Final = 10.0
|
|
23
|
+
DEFAULT_MAX_RESPONSE_BODY_BYTES: typing.Final = 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
27
|
+
class _Key:
|
|
28
|
+
jwk: dict[str, typing.Any]
|
|
29
|
+
key: jwt.PyJWK
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_key_set(payload: object) -> dict[str, _Key]:
|
|
33
|
+
if not isinstance(payload, dict):
|
|
34
|
+
msg = "The JWKS endpoint did not return a JSON object"
|
|
35
|
+
raise JWKSFetchError(msg)
|
|
36
|
+
raw_keys: typing.Final = payload.get("keys")
|
|
37
|
+
if not isinstance(raw_keys, list):
|
|
38
|
+
msg = "The JWKS endpoint did not return a 'keys' list"
|
|
39
|
+
raise JWKSFetchError(msg)
|
|
40
|
+
|
|
41
|
+
keys: typing.Final[dict[str, _Key]] = {}
|
|
42
|
+
for jwk in raw_keys:
|
|
43
|
+
if not isinstance(jwk, dict) or not isinstance(jwk.get("kid"), str) or jwk.get("use", "sig") != "sig":
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
keys.setdefault(jwk["kid"], _Key(jwk=jwk, key=jwt.PyJWK(jwk)))
|
|
47
|
+
except jwt.PyJWTError:
|
|
48
|
+
LOGGER.debug("skipping unusable JWK %r", jwk["kid"], exc_info=True)
|
|
49
|
+
|
|
50
|
+
if not keys:
|
|
51
|
+
msg = "The JWKS endpoint did not contain any usable signing key"
|
|
52
|
+
raise JWKSFetchError(msg)
|
|
53
|
+
return keys
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _require_duration(name: str, value: float, *, allow_zero: bool) -> None:
|
|
57
|
+
if not math.isfinite(value) or value < 0 or (value == 0 and not allow_zero):
|
|
58
|
+
bound: typing.Final = ">= 0" if allow_zero else "> 0"
|
|
59
|
+
msg = f"{name} must be finite and {bound}, got {value!r}"
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _create_default_http_client() -> httpware.AsyncClient:
|
|
64
|
+
return httpware.AsyncClient(
|
|
65
|
+
timeout=DEFAULT_TIMEOUT,
|
|
66
|
+
max_response_body_bytes=DEFAULT_MAX_RESPONSE_BODY_BYTES,
|
|
67
|
+
middleware=[httpware.AsyncCircuitBreaker(), httpware.AsyncRetry()],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class JWKSClient:
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
uri: str,
|
|
75
|
+
*,
|
|
76
|
+
http_client: httpware.AsyncClient | None = None,
|
|
77
|
+
ttl: float = DEFAULT_TTL,
|
|
78
|
+
refetch_cooldown: float = DEFAULT_REFETCH_COOLDOWN,
|
|
79
|
+
stale_if_error: float = DEFAULT_STALE_IF_ERROR,
|
|
80
|
+
clock: Callable[[], float] = time.monotonic,
|
|
81
|
+
) -> None:
|
|
82
|
+
if urllib.parse.urlsplit(uri).scheme.lower() not in {"http", "https"}:
|
|
83
|
+
msg = f"JWKS URI must use http or https, got {uri!r}"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
_require_duration("ttl", ttl, allow_zero=False)
|
|
86
|
+
_require_duration("refetch_cooldown", refetch_cooldown, allow_zero=True)
|
|
87
|
+
_require_duration("stale_if_error", stale_if_error, allow_zero=True)
|
|
88
|
+
|
|
89
|
+
self.uri: typing.Final = uri
|
|
90
|
+
self._owns_http_client: typing.Final = http_client is None
|
|
91
|
+
self._http_client: typing.Final = http_client or _create_default_http_client()
|
|
92
|
+
self._ttl: typing.Final = ttl
|
|
93
|
+
self._refetch_cooldown: typing.Final = refetch_cooldown
|
|
94
|
+
self._stale_if_error: typing.Final = stale_if_error
|
|
95
|
+
self._clock: typing.Final = clock
|
|
96
|
+
self._lock: typing.Final = asyncio.Lock()
|
|
97
|
+
self._keys: dict[str, _Key] = {}
|
|
98
|
+
self._fetched_at: float | None = None
|
|
99
|
+
self._attempted_at: float | None = None
|
|
100
|
+
self._last_error: JWKSFetchError | None = None
|
|
101
|
+
|
|
102
|
+
async def __aenter__(self) -> typing.Self:
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
async def __aexit__(self, *_: object) -> None:
|
|
106
|
+
await self.aclose()
|
|
107
|
+
|
|
108
|
+
async def aclose(self) -> None:
|
|
109
|
+
if self._owns_http_client:
|
|
110
|
+
await self._http_client.aclose()
|
|
111
|
+
|
|
112
|
+
async def refresh(self) -> None:
|
|
113
|
+
async with self._lock:
|
|
114
|
+
await self._fetch()
|
|
115
|
+
|
|
116
|
+
async def get_signing_key(self, kid: str) -> jwt.PyJWK:
|
|
117
|
+
return (await self._find(kid)).key
|
|
118
|
+
|
|
119
|
+
async def get_signing_key_from_jwt(self, token: str | bytes) -> jwt.PyJWK:
|
|
120
|
+
return (await self._find(jwt.get_unverified_header(token).get("kid"))).key
|
|
121
|
+
|
|
122
|
+
async def decode(
|
|
123
|
+
self,
|
|
124
|
+
token: str | bytes,
|
|
125
|
+
*,
|
|
126
|
+
algorithms: Sequence[str],
|
|
127
|
+
audience: str | Iterable[str] | None = None,
|
|
128
|
+
issuer: str | Container[str] | None = None,
|
|
129
|
+
leeway: float | datetime.timedelta = 0,
|
|
130
|
+
options: Options | None = None,
|
|
131
|
+
) -> dict[str, typing.Any]:
|
|
132
|
+
header: typing.Final = jwt.get_unverified_header(token)
|
|
133
|
+
algorithm: typing.Final = header.get("alg")
|
|
134
|
+
if not isinstance(algorithm, str) or algorithm not in algorithms:
|
|
135
|
+
msg = "The specified alg value is not allowed"
|
|
136
|
+
raise jwt.InvalidAlgorithmError(msg)
|
|
137
|
+
|
|
138
|
+
found: typing.Final = await self._find(header.get("kid"))
|
|
139
|
+
key: typing.Final = found.key if "alg" in found.jwk else self._bind_algorithm(found, algorithm)
|
|
140
|
+
return jwt.decode(
|
|
141
|
+
token,
|
|
142
|
+
key,
|
|
143
|
+
algorithms=list(algorithms),
|
|
144
|
+
audience=audience,
|
|
145
|
+
issuer=issuer,
|
|
146
|
+
leeway=leeway,
|
|
147
|
+
options=options,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _bind_algorithm(found: _Key, algorithm: str) -> jwt.PyJWK:
|
|
152
|
+
try:
|
|
153
|
+
return jwt.PyJWK(found.jwk, algorithm=algorithm)
|
|
154
|
+
except (jwt.PyJWTError, NotImplementedError) as exc:
|
|
155
|
+
msg = f"Algorithm {algorithm!r} cannot be used with key {found.key.key_id!r}"
|
|
156
|
+
raise jwt.InvalidAlgorithmError(msg) from exc
|
|
157
|
+
|
|
158
|
+
async def _find(self, kid: object) -> _Key:
|
|
159
|
+
if not isinstance(kid, str):
|
|
160
|
+
msg = "The token has no 'kid' header"
|
|
161
|
+
raise KeyNotFoundError(msg)
|
|
162
|
+
await self._ensure_fresh()
|
|
163
|
+
if kid not in self._keys:
|
|
164
|
+
await self._refetch_for_unknown(kid)
|
|
165
|
+
try:
|
|
166
|
+
return self._keys[kid]
|
|
167
|
+
except KeyError:
|
|
168
|
+
msg = f"Unable to find a signing key that matches: {kid!r}"
|
|
169
|
+
raise KeyNotFoundError(msg) from None
|
|
170
|
+
|
|
171
|
+
def _is_fresh(self) -> bool:
|
|
172
|
+
return self._fetched_at is not None and self._clock() - self._fetched_at < self._ttl
|
|
173
|
+
|
|
174
|
+
def _is_cooling_down(self) -> bool:
|
|
175
|
+
return self._attempted_at is not None and self._clock() - self._attempted_at < self._refetch_cooldown
|
|
176
|
+
|
|
177
|
+
def _can_serve_stale(self) -> bool:
|
|
178
|
+
return self._fetched_at is not None and self._clock() - self._fetched_at < self._ttl + self._stale_if_error
|
|
179
|
+
|
|
180
|
+
async def _ensure_fresh(self) -> None:
|
|
181
|
+
if self._is_fresh():
|
|
182
|
+
return
|
|
183
|
+
async with self._lock:
|
|
184
|
+
if self._is_fresh():
|
|
185
|
+
return
|
|
186
|
+
if self._last_error is not None and self._is_cooling_down():
|
|
187
|
+
if self._can_serve_stale():
|
|
188
|
+
return
|
|
189
|
+
msg = "The last JWKS fetch failed; not retrying until the cooldown elapses"
|
|
190
|
+
raise JWKSFetchError(msg) from self._last_error
|
|
191
|
+
try:
|
|
192
|
+
await self._fetch()
|
|
193
|
+
except JWKSFetchError:
|
|
194
|
+
if not self._can_serve_stale():
|
|
195
|
+
raise
|
|
196
|
+
LOGGER.warning("JWKS refresh failed, serving stale keys from %s", self.uri, exc_info=True)
|
|
197
|
+
|
|
198
|
+
async def _refetch_for_unknown(self, kid: str) -> None:
|
|
199
|
+
async with self._lock:
|
|
200
|
+
if kid in self._keys or self._is_cooling_down():
|
|
201
|
+
return
|
|
202
|
+
await self._fetch()
|
|
203
|
+
|
|
204
|
+
async def _fetch(self) -> None:
|
|
205
|
+
self._attempted_at = self._clock()
|
|
206
|
+
try:
|
|
207
|
+
keys: typing.Final = _parse_key_set(await self._download())
|
|
208
|
+
except JWKSFetchError as exc:
|
|
209
|
+
self._last_error = exc
|
|
210
|
+
raise
|
|
211
|
+
self._keys = keys
|
|
212
|
+
self._fetched_at = self._attempted_at
|
|
213
|
+
self._last_error = None
|
|
214
|
+
|
|
215
|
+
async def _download(self) -> object:
|
|
216
|
+
try:
|
|
217
|
+
response: typing.Final = await self._http_client.get(self.uri)
|
|
218
|
+
return response.json()
|
|
219
|
+
except (httpware.ClientError, ValueError) as exc:
|
|
220
|
+
msg = f"Failed to fetch JWKS from {self.uri}: {exc}"
|
|
221
|
+
raise JWKSFetchError(msg) from exc
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import jwt
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class JWKSError(Exception):
|
|
5
|
+
"""Base class for every error jwks-client raises."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JWKSFetchError(JWKSError):
|
|
9
|
+
"""The JWKS endpoint could not be fetched, or returned no usable signing key."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KeyNotFoundError(JWKSError, jwt.InvalidTokenError):
|
|
13
|
+
"""No signing key in the JWKS matches the token's ``kid``."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import jwt
|
|
4
|
+
|
|
5
|
+
from jwks_client.errors import JWKSFetchError
|
|
6
|
+
from jwks_client.verifier import TokenVerifier, UserParser, extract_bearer_token
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from fastapi import HTTPException, Request, status
|
|
11
|
+
from fastapi.openapi.models import HTTPBearer, OpenIdConnect
|
|
12
|
+
from fastapi.security.base import SecurityBase
|
|
13
|
+
except ImportError as exc:
|
|
14
|
+
msg = "jwks_client.fastapi requires the fastapi extra: pip install 'jwks-client[fastapi]'"
|
|
15
|
+
raise ImportError(msg) from exc
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
UNAUTHORIZED_HEADERS: typing.Final = {"WWW-Authenticate": "Bearer"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _create_unauthorized_error() -> HTTPException:
|
|
22
|
+
return HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers=UNAUTHORIZED_HEADERS)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JWKSBearer(SecurityBase):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
verifier: TokenVerifier,
|
|
29
|
+
*,
|
|
30
|
+
user_parser: UserParser | None = None,
|
|
31
|
+
openid_configuration_url: str | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.verifier: typing.Final = verifier
|
|
34
|
+
self.user_parser: typing.Final = user_parser
|
|
35
|
+
if openid_configuration_url is None:
|
|
36
|
+
self.model = HTTPBearer(bearerFormat="JWT")
|
|
37
|
+
self.scheme_name = "Bearer"
|
|
38
|
+
else:
|
|
39
|
+
self.model = OpenIdConnect(openIdConnectUrl=openid_configuration_url)
|
|
40
|
+
self.scheme_name = "OpenIdConnect"
|
|
41
|
+
|
|
42
|
+
async def __call__(self, request: Request) -> typing.Any: # noqa: ANN401
|
|
43
|
+
token: typing.Final = extract_bearer_token(request.headers.get("Authorization"))
|
|
44
|
+
if token is None:
|
|
45
|
+
raise _create_unauthorized_error()
|
|
46
|
+
try:
|
|
47
|
+
claims: typing.Final = await self.verifier.verify(token)
|
|
48
|
+
except jwt.InvalidTokenError as exc:
|
|
49
|
+
raise _create_unauthorized_error() from exc
|
|
50
|
+
except JWKSFetchError as exc:
|
|
51
|
+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="Identity provider unavailable") from exc
|
|
52
|
+
|
|
53
|
+
user: typing.Final = claims if self.user_parser is None else self.user_parser(claims)
|
|
54
|
+
if user is None:
|
|
55
|
+
raise _create_unauthorized_error()
|
|
56
|
+
return user
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
|
|
4
|
+
import jwt
|
|
5
|
+
|
|
6
|
+
from jwks_client.errors import JWKSFetchError
|
|
7
|
+
from jwks_client.verifier import TokenVerifier, UserParser, extract_bearer_token
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from litestar.connection import ASGIConnection
|
|
12
|
+
from litestar.exceptions import NotAuthorizedException, ServiceUnavailableException
|
|
13
|
+
from litestar.middleware import AbstractAuthenticationMiddleware, AuthenticationResult
|
|
14
|
+
from litestar.openapi.spec import Components, SecurityRequirement, SecurityScheme
|
|
15
|
+
from litestar.types import ASGIApp, Method, Scopes
|
|
16
|
+
except ImportError as exc:
|
|
17
|
+
msg = "jwks_client.litestar requires the litestar extra: pip install 'jwks-client[litestar]'"
|
|
18
|
+
raise ImportError(msg) from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
UNAUTHORIZED_HEADERS: typing.Final = {"WWW-Authenticate": "Bearer"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JWKSAuthMiddleware(AbstractAuthenticationMiddleware):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
app: ASGIApp,
|
|
28
|
+
*,
|
|
29
|
+
verifier: TokenVerifier,
|
|
30
|
+
user_parser: UserParser | None = None,
|
|
31
|
+
exclude: str | list[str] | None = None,
|
|
32
|
+
exclude_from_auth_key: str = "exclude_from_auth",
|
|
33
|
+
exclude_http_methods: Sequence[Method] | None = None,
|
|
34
|
+
scopes: "Scopes | None" = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(app, exclude, exclude_from_auth_key, exclude_http_methods, scopes)
|
|
37
|
+
self.verifier: typing.Final = verifier
|
|
38
|
+
self.user_parser: typing.Final = user_parser
|
|
39
|
+
|
|
40
|
+
async def authenticate_request(
|
|
41
|
+
self, connection: ASGIConnection[typing.Any, typing.Any, typing.Any, typing.Any]
|
|
42
|
+
) -> AuthenticationResult:
|
|
43
|
+
token: typing.Final = extract_bearer_token(connection.headers.get("Authorization"))
|
|
44
|
+
if token is None:
|
|
45
|
+
raise NotAuthorizedException(headers=UNAUTHORIZED_HEADERS)
|
|
46
|
+
try:
|
|
47
|
+
claims: typing.Final = await self.verifier.verify(token)
|
|
48
|
+
except jwt.InvalidTokenError as exc:
|
|
49
|
+
raise NotAuthorizedException(headers=UNAUTHORIZED_HEADERS) from exc
|
|
50
|
+
except JWKSFetchError as exc:
|
|
51
|
+
raise ServiceUnavailableException from exc
|
|
52
|
+
|
|
53
|
+
user: typing.Final = claims if self.user_parser is None else self.user_parser(claims)
|
|
54
|
+
if user is None:
|
|
55
|
+
raise NotAuthorizedException(headers=UNAUTHORIZED_HEADERS)
|
|
56
|
+
return AuthenticationResult(user=user, auth=claims)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OpenAPISecurityConfig(typing.TypedDict):
|
|
60
|
+
components: Components
|
|
61
|
+
security: list[SecurityRequirement]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_openapi_security_config(openid_configuration_url: str | None = None) -> OpenAPISecurityConfig:
|
|
65
|
+
schemes: typing.Final[dict[str, SecurityScheme]] = {
|
|
66
|
+
"Bearer": SecurityScheme(type="http", scheme="bearer", bearer_format="JWT"),
|
|
67
|
+
}
|
|
68
|
+
if openid_configuration_url is not None:
|
|
69
|
+
schemes["OpenIdConnect"] = SecurityScheme(type="openIdConnect", open_id_connect_url=openid_configuration_url)
|
|
70
|
+
return {
|
|
71
|
+
"components": Components(security_schemes=dict(schemes)),
|
|
72
|
+
"security": [{name: []} for name in schemes],
|
|
73
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import typing
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
import httpware
|
|
7
|
+
import httpx2
|
|
8
|
+
import jwt
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
10
|
+
from jwt.algorithms import RSAAlgorithm
|
|
11
|
+
|
|
12
|
+
from jwks_client.client import JWKSClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_TOKEN_LIFETIME: typing.Final = datetime.timedelta(minutes=5)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FakeIdentityProvider:
|
|
19
|
+
def __init__(self, *, issuer: str = "https://idp.test", kid: str = "test-key") -> None:
|
|
20
|
+
self.issuer: typing.Final = issuer
|
|
21
|
+
self.kid: typing.Final = kid
|
|
22
|
+
self.jwks_uri: typing.Final = f"{issuer}/.well-known/jwks.json"
|
|
23
|
+
self._private_key: typing.Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
24
|
+
jwk: typing.Final = json.loads(RSAAlgorithm.to_jwk(self._private_key.public_key()))
|
|
25
|
+
self.jwks: typing.Final[dict[str, typing.Any]] = {"keys": [jwk | {"kid": kid, "use": "sig", "alg": "RS256"}]}
|
|
26
|
+
|
|
27
|
+
def issue_token(
|
|
28
|
+
self,
|
|
29
|
+
claims: Mapping[str, typing.Any] | None = None,
|
|
30
|
+
*,
|
|
31
|
+
expires_in: datetime.timedelta = DEFAULT_TOKEN_LIFETIME,
|
|
32
|
+
headers: Mapping[str, typing.Any] | None = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
now: typing.Final = datetime.datetime.now(tz=datetime.UTC)
|
|
35
|
+
payload: typing.Final = {"iss": self.issuer, "iat": now, "exp": now + expires_in} | dict(claims or {})
|
|
36
|
+
return jwt.encode(
|
|
37
|
+
payload, self._private_key, algorithm="RS256", headers={"kid": self.kid} | dict(headers or {})
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def create_jwks_client(self) -> JWKSClient:
|
|
41
|
+
transport: typing.Final = httpx2.MockTransport(lambda _: httpx2.Response(200, json=self.jwks))
|
|
42
|
+
return JWKSClient(
|
|
43
|
+
self.jwks_uri, http_client=httpware.AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport))
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StaticTokenVerifier:
|
|
48
|
+
def __init__(self, tokens: Mapping[str, Mapping[str, typing.Any]]) -> None:
|
|
49
|
+
self._tokens: typing.Final = tokens
|
|
50
|
+
|
|
51
|
+
async def verify(self, token: str) -> dict[str, typing.Any]:
|
|
52
|
+
try:
|
|
53
|
+
return dict(self._tokens[token])
|
|
54
|
+
except KeyError:
|
|
55
|
+
msg = "Unknown token"
|
|
56
|
+
raise jwt.InvalidTokenError(msg) from None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import typing
|
|
3
|
+
from collections.abc import Callable, Container, Iterable, Sequence
|
|
4
|
+
|
|
5
|
+
from jwt.types import Options
|
|
6
|
+
|
|
7
|
+
from jwks_client.client import JWKSClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
BEARER_SCHEME: typing.Final = "bearer"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
UserParser = Callable[[dict[str, typing.Any]], typing.Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenVerifier(typing.Protocol):
|
|
17
|
+
async def verify(self, token: str) -> dict[str, typing.Any]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JWTVerifier:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
jwks: JWKSClient,
|
|
24
|
+
*,
|
|
25
|
+
algorithms: Sequence[str],
|
|
26
|
+
audience: str | Iterable[str] | None = None,
|
|
27
|
+
issuer: str | Container[str] | None = None,
|
|
28
|
+
leeway: float | datetime.timedelta = 0,
|
|
29
|
+
options: Options | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
if not algorithms:
|
|
32
|
+
msg = "algorithms must name at least one signing algorithm"
|
|
33
|
+
raise ValueError(msg)
|
|
34
|
+
self.jwks: typing.Final = jwks
|
|
35
|
+
self.algorithms: typing.Final = tuple(algorithms)
|
|
36
|
+
self.audience: typing.Final = audience
|
|
37
|
+
self.issuer: typing.Final = issuer
|
|
38
|
+
self.leeway: typing.Final = leeway
|
|
39
|
+
self.options: typing.Final = options
|
|
40
|
+
|
|
41
|
+
async def verify(self, token: str) -> dict[str, typing.Any]:
|
|
42
|
+
return await self.jwks.decode(
|
|
43
|
+
token,
|
|
44
|
+
algorithms=self.algorithms,
|
|
45
|
+
audience=self.audience,
|
|
46
|
+
issuer=self.issuer,
|
|
47
|
+
leeway=self.leeway,
|
|
48
|
+
options=self.options,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_bearer_token(authorization: str | None) -> str | None:
|
|
53
|
+
scheme, _, token = (authorization or "").strip().partition(" ")
|
|
54
|
+
token = token.strip()
|
|
55
|
+
if scheme.lower() != BEARER_SCHEME or not token:
|
|
56
|
+
return None
|
|
57
|
+
return token
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jwks-client"
|
|
3
|
+
description = "Async JWKS client for verifying JWTs, with key caching, resilient fetching, and Litestar and FastAPI integrations"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.11,<4"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
keywords = [
|
|
8
|
+
"jwks",
|
|
9
|
+
"jwt",
|
|
10
|
+
"jwk",
|
|
11
|
+
"oidc",
|
|
12
|
+
"oauth2",
|
|
13
|
+
"authentication",
|
|
14
|
+
"asyncio",
|
|
15
|
+
"litestar",
|
|
16
|
+
"fastapi",
|
|
17
|
+
"python",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Programming Language :: Python :: Free Threading :: 2 - Beta",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
"Topic :: Security",
|
|
29
|
+
"Topic :: Software Development :: Libraries",
|
|
30
|
+
"Framework :: AsyncIO",
|
|
31
|
+
"Framework :: FastAPI",
|
|
32
|
+
"Framework :: Litestar",
|
|
33
|
+
]
|
|
34
|
+
version = "0.1.0"
|
|
35
|
+
dependencies = [
|
|
36
|
+
"pyjwt[crypto]>=2.15.1",
|
|
37
|
+
"httpware>=0.15.0,<1",
|
|
38
|
+
"httpx2>=2.0.0,<3",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[[project.authors]]
|
|
42
|
+
name = "Artur Shiriev"
|
|
43
|
+
email = "me@shiriev.ru"
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
litestar = ["litestar>=2.19"]
|
|
47
|
+
fastapi = ["fastapi>=0.115"]
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://modern-python.org"
|
|
51
|
+
Repository = "https://github.com/modern-python/jwks-client"
|
|
52
|
+
Issues = "https://github.com/modern-python/jwks-client/issues"
|
|
53
|
+
Changelog = "https://github.com/modern-python/jwks-client/releases"
|
|
54
|
+
|
|
55
|
+
[dependency-groups]
|
|
56
|
+
dev = [
|
|
57
|
+
"pytest>=8",
|
|
58
|
+
"pytest-cov>=5",
|
|
59
|
+
"pytest-asyncio>=0.24",
|
|
60
|
+
]
|
|
61
|
+
lint = [
|
|
62
|
+
"ruff",
|
|
63
|
+
"ty",
|
|
64
|
+
"eof-fixer",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[build-system]
|
|
68
|
+
requires = ["uv_build>=0.11,<1.0"]
|
|
69
|
+
build-backend = "uv_build"
|
|
70
|
+
|
|
71
|
+
[tool.uv.build-backend]
|
|
72
|
+
module-name = "jwks_client"
|
|
73
|
+
module-root = ""
|
|
74
|
+
|
|
75
|
+
[tool.ruff]
|
|
76
|
+
fix = true
|
|
77
|
+
unsafe-fixes = true
|
|
78
|
+
line-length = 120
|
|
79
|
+
|
|
80
|
+
[tool.ruff.format]
|
|
81
|
+
docstring-code-format = true
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint]
|
|
84
|
+
select = ["ALL"]
|
|
85
|
+
ignore = [
|
|
86
|
+
"D1",
|
|
87
|
+
"D203",
|
|
88
|
+
"D213",
|
|
89
|
+
"COM812",
|
|
90
|
+
"ISC001",
|
|
91
|
+
"CPY001",
|
|
92
|
+
"FBT",
|
|
93
|
+
"TCH",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint.pylint]
|
|
97
|
+
max-args = 10
|
|
98
|
+
|
|
99
|
+
[tool.ruff.lint.isort]
|
|
100
|
+
lines-after-imports = 2
|
|
101
|
+
no-lines-before = [
|
|
102
|
+
"standard-library",
|
|
103
|
+
"local-folder",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint.per-file-ignores]
|
|
107
|
+
"tests/**" = [
|
|
108
|
+
"S101",
|
|
109
|
+
"PLR2004",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
[tool.pytest.ini_options]
|
|
113
|
+
testpaths = ["tests"]
|
|
114
|
+
addopts = ""
|
|
115
|
+
asyncio_mode = "auto"
|
|
116
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
117
|
+
|
|
118
|
+
[tool.coverage.report]
|
|
119
|
+
fail_under = 100
|
|
120
|
+
exclude_also = ["if typing.TYPE_CHECKING:"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jwks-client"
|
|
3
|
+
description = "Async JWKS client for verifying JWTs, with key caching, resilient fetching, and Litestar and FastAPI integrations"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Artur Shiriev", email = "me@shiriev.ru" },
|
|
6
|
+
]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.11,<4"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
keywords = ["jwks", "jwt", "jwk", "oidc", "oauth2", "authentication", "asyncio", "litestar", "fastapi", "python"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Programming Language :: Python :: Free Threading :: 2 - Beta",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Framework :: AsyncIO",
|
|
23
|
+
"Framework :: FastAPI",
|
|
24
|
+
"Framework :: Litestar",
|
|
25
|
+
]
|
|
26
|
+
version = "0.1.0"
|
|
27
|
+
dependencies = [
|
|
28
|
+
# 2.15.1 carries the fixes for the 2026 advisories and binds a PyJWK's algorithm at verify time.
|
|
29
|
+
"pyjwt[crypto]>=2.15.1",
|
|
30
|
+
# 0.15.0 introduced max_response_body_bytes.
|
|
31
|
+
"httpware>=0.15.0,<1",
|
|
32
|
+
"httpx2>=2.0.0,<3",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
litestar = ["litestar>=2.19"]
|
|
37
|
+
fastapi = ["fastapi>=0.115"]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://modern-python.org"
|
|
41
|
+
Repository = "https://github.com/modern-python/jwks-client"
|
|
42
|
+
Issues = "https://github.com/modern-python/jwks-client/issues"
|
|
43
|
+
Changelog = "https://github.com/modern-python/jwks-client/releases"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8",
|
|
48
|
+
"pytest-cov>=5",
|
|
49
|
+
"pytest-asyncio>=0.24",
|
|
50
|
+
]
|
|
51
|
+
lint = [
|
|
52
|
+
"ruff",
|
|
53
|
+
"ty",
|
|
54
|
+
"eof-fixer",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[build-system]
|
|
58
|
+
requires = ["uv_build>=0.11,<1.0"]
|
|
59
|
+
build-backend = "uv_build"
|
|
60
|
+
|
|
61
|
+
[tool.uv.build-backend]
|
|
62
|
+
module-name = "jwks_client"
|
|
63
|
+
module-root = ""
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
fix = true
|
|
67
|
+
unsafe-fixes = true
|
|
68
|
+
line-length = 120
|
|
69
|
+
|
|
70
|
+
[tool.ruff.format]
|
|
71
|
+
docstring-code-format = true
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
select = ["ALL"]
|
|
75
|
+
ignore = [
|
|
76
|
+
"D1", # docstrings are not forced; a docstring exists when it says something
|
|
77
|
+
"D203", # conflicts with D211
|
|
78
|
+
"D213", # conflicts with D212
|
|
79
|
+
"COM812", # conflicts with the formatter
|
|
80
|
+
"ISC001", # conflicts with the formatter
|
|
81
|
+
"CPY001", # no per-file copyright header
|
|
82
|
+
"FBT", # boolean positional arguments are fine
|
|
83
|
+
"TCH", # imports stay real; TYPE_CHECKING-only imports break runtime introspection
|
|
84
|
+
]
|
|
85
|
+
pylint.max-args = 10
|
|
86
|
+
isort.lines-after-imports = 2
|
|
87
|
+
isort.no-lines-before = ["standard-library", "local-folder"]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.per-file-ignores]
|
|
90
|
+
"tests/**" = [
|
|
91
|
+
"S101", # assert is the test idiom; in library code it is a finding
|
|
92
|
+
"PLR2004", # request counts read clearer as literals than as named constants
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
[tool.pytest.ini_options]
|
|
96
|
+
testpaths = ["tests"]
|
|
97
|
+
addopts = ""
|
|
98
|
+
asyncio_mode = "auto"
|
|
99
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
100
|
+
|
|
101
|
+
[tool.coverage.report]
|
|
102
|
+
fail_under = 100
|
|
103
|
+
exclude_also = [
|
|
104
|
+
"if typing.TYPE_CHECKING:",
|
|
105
|
+
]
|