Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

## [0.8.0] - 2024-08-07
## [0.8.0] - 2025-07-28

### Added

- Use `jwks_url` as a fallback if the `jwks_uri` is not defined in the configuration.
This makes it possible to use a broader selection of JWT providers.
- Added `AsyncKeyFetcher.aclose` method to close fetcher's http client directly.
- Updated http client to use acloseable context manager, so it can be used with
`async with` to ensure resources are cleaned up properly.

### Removed

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ from pyjwt_key_fetcher import AsyncKeyFetcher


async def main():
# create a single instance once for application
fetcher = AsyncKeyFetcher()

# Token and options copied from
# https://pyjwt.readthedocs.io/en/2.6.0/usage.html#retrieve-rsa-signing-keys-from-a-jwks-endpoint
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FRTFRVVJCT1RNNE16STVSa0ZETlRZeE9UVTFNRGcyT0Rnd1EwVXpNVGsxUWpZeVJrUkZRdyJ9.eyJpc3MiOiJodHRwczovL2Rldi04N2V2eDlydS5hdXRoMC5jb20vIiwic3ViIjoiYVc0Q2NhNzl4UmVMV1V6MGFFMkg2a0QwTzNjWEJWdENAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZXhwZW5zZXMtYXBpIiwiaWF0IjoxNTcyMDA2OTU0LCJleHAiOjE1NzIwMDY5NjQsImF6cCI6ImFXNENjYTc5eFJlTFdVejBhRTJINmtEME8zY1hCVnRDIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.PUxE7xn52aTCohGiWoSdMBZGiYAHwE5FYie0Y1qUT68IHSTXwXVd6hn02HTah6epvHHVKA2FqcFZ4GGv5VTHEvYpeggiiZMgbxFrmTEY0csL6VNkX1eaJGcuehwQCRBKRLL3zKmA5IKGy5GeUnIbpPHLHDxr-GXvgFzsdsyWlVQvPX2xjeaQ217r2PtxDeqjlf66UYl6oY6AqNS8DH3iryCvIfCcybRZkc_hdy-6ZMoKT6Piijvk_aXdm7-QQqKJFHLuEqrVSOuBqqiNfVrG27QzAPuPOxvfXTVLXL2jek5meH6n-VWgrBdoMFH93QEszEDowDAEhQPHVs0xj7SIzA"
Expand All @@ -55,6 +57,9 @@ async def main():
**key_entry
)
print(token)

# close the fetcher to clean up resources on app shutdown
await fetcher.aclose()


if __name__ == "__main__":
Expand Down
14 changes: 13 additions & 1 deletion pyjwt_key_fetcher/fetcher.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import AbstractAsyncContextManager
from typing import Any, Dict, Iterable, Mapping, MutableMapping, Optional

import jwt
Expand All @@ -9,7 +10,7 @@
from pyjwt_key_fetcher.provider import ConfigurationTypeDef, Provider


class AsyncKeyFetcher:
class AsyncKeyFetcher(AbstractAsyncContextManager):
def __init__(
self,
valid_issuers: Optional[Iterable[str]] = None,
Expand Down Expand Up @@ -157,3 +158,14 @@ async def get_key(self, token: str) -> Key:
iss = self.get_issuer(token)
key = await self.get_key_by_iss_and_kid(iss=iss, kid=kid)
return key

async def aclose(self) -> None:
"""Close the HTTP client session if applicable.

This method should be called to clean up resources.
"""
await self._http_client.aclose()

async def __aexit__(self, *_exc: object) -> None:
"""Close the HTTP client session when exiting the context manager."""
await self.aclose()
24 changes: 23 additions & 1 deletion pyjwt_key_fetcher/http_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import abc
from contextlib import AbstractAsyncContextManager
from json import JSONDecodeError
from typing import Any, Dict

Expand All @@ -7,7 +8,7 @@
from pyjwt_key_fetcher.errors import JWTHTTPFetchError


class HTTPClient(abc.ABC):
class HTTPClient(AbstractAsyncContextManager, abc.ABC):
"""
Abstract base class for HTTP Clients used to fetch the configuration and JWKs in
JSON format.
Expand All @@ -24,6 +25,13 @@ async def get_json(self, url: str) -> Dict[str, Any]:
"""
raise NotImplementedError

async def aclose(self) -> None:
"""Close the HTTP client session if applicable.

This method should be called to clean up resources.
"""
raise NotImplementedError


class DefaultHTTPClient(HTTPClient):
"""
Expand Down Expand Up @@ -53,3 +61,17 @@ async def get_json(self, url: str) -> Dict[str, Any]:
raise JWTHTTPFetchError(f"Failed to fetch or decode {url}") from e

return data

async def aclose(self) -> None:
"""Close the HTTP client session if applicable.

This method should be called to clean up resources.
"""
if self.session.closed:
return

await self.session.close()

async def __aexit__(self, *_exc: object) -> None:
"""Close the HTTP client session when exiting the context manager."""
await self.aclose()
13 changes: 13 additions & 0 deletions pyjwt_key_fetcher/tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,16 @@ async def test_get_aio_error(exception):
client.session = create_fake_session(exception=exception)
with pytest.raises(JWTHTTPFetchError):
await client.get_json("https://example.com")


@pytest.mark.asyncio
async def test_aclose():
client = DefaultHTTPClient()
client.session = create_fake_session()
await client.aclose()


@pytest.mark.asyncio
async def test_context_manager():
async with DefaultHTTPClient() as client:
client.session = create_fake_session()
12 changes: 12 additions & 0 deletions pyjwt_key_fetcher/tests/test_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,15 @@ async def test_static_issuer_config(jwks_uri_field):

provider_config = await provider.get_configuration()
assert provider_config == {jwks_uri_field: f"{issuer}/.well-known/jwks.json"}


@pytest.mark.asyncio
async def test_aclose():
fetcher = pyjwt_key_fetcher.AsyncKeyFetcher()
await fetcher.aclose()


@pytest.mark.asyncio
async def test_context_manager():
async with pyjwt_key_fetcher.AsyncKeyFetcher():
...