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.
@@ -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
+ ]