diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a778f..aa51742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 41ee13d..c5249e6 100644 --- a/README.md +++ b/README.md @@ -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" @@ -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__": diff --git a/pyjwt_key_fetcher/fetcher.py b/pyjwt_key_fetcher/fetcher.py index 7e074c5..e41dbb5 100644 --- a/pyjwt_key_fetcher/fetcher.py +++ b/pyjwt_key_fetcher/fetcher.py @@ -1,3 +1,4 @@ +from contextlib import AbstractAsyncContextManager from typing import Any, Dict, Iterable, Mapping, MutableMapping, Optional import jwt @@ -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, @@ -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() diff --git a/pyjwt_key_fetcher/http_client.py b/pyjwt_key_fetcher/http_client.py index 6f970c2..86c209e 100644 --- a/pyjwt_key_fetcher/http_client.py +++ b/pyjwt_key_fetcher/http_client.py @@ -1,4 +1,5 @@ import abc +from contextlib import AbstractAsyncContextManager from json import JSONDecodeError from typing import Any, Dict @@ -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. @@ -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): """ @@ -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() diff --git a/pyjwt_key_fetcher/tests/test_clients.py b/pyjwt_key_fetcher/tests/test_clients.py index db5d4d2..5243da8 100644 --- a/pyjwt_key_fetcher/tests/test_clients.py +++ b/pyjwt_key_fetcher/tests/test_clients.py @@ -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() diff --git a/pyjwt_key_fetcher/tests/test_fetcher.py b/pyjwt_key_fetcher/tests/test_fetcher.py index 83db9b4..b2ccfec 100644 --- a/pyjwt_key_fetcher/tests/test_fetcher.py +++ b/pyjwt_key_fetcher/tests/test_fetcher.py @@ -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(): + ...