-
Notifications
You must be signed in to change notification settings - Fork 60
Adding PyJWKClient caching #338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
3cb5121
Adding PyJWKClient caching
rodrigobr-msft 3155b54
Formatting and documentation
rodrigobr-msft 504f38e
Removing unnecessary import
rodrigobr-msft d94b4bf
More formatting
rodrigobr-msft ade8d58
Locking _get_jwk_client
rodrigobr-msft ceb4103
Moving jwk client caching to its own class
rodrigobr-msft 4713ac4
Adding tests for token validation helper class
rodrigobr-msft 830825b
formatting
rodrigobr-msft 6d4a5bb
Using a threading.Lock instead
rodrigobr-msft 96258a2
Formatting
rodrigobr-msft File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
128 changes: 128 additions & 0 deletions
128
tests/hosting_core/authorization/test_jwk_client_manager.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import asyncio | ||
| import threading | ||
| import time | ||
|
|
||
| import pytest | ||
| from jwt import PyJWKClient | ||
|
|
||
| from microsoft_agents.hosting.core.authorization.jwt_token_validator import ( | ||
| _JwkClientManager, | ||
| ) | ||
|
|
||
|
|
||
| async def _wait_until_set(event: threading.Event, timeout: float = 1.0) -> None: | ||
| start = time.monotonic() | ||
| while not event.is_set(): | ||
| if time.monotonic() - start > timeout: | ||
| raise AssertionError("Timed out waiting for threading event.") | ||
| await asyncio.sleep(0.01) | ||
|
|
||
|
|
||
| class TestJwkClientManager: | ||
| def test_get_jwk_client_reuses_cache_for_same_uri(self): | ||
| manager = _JwkClientManager() | ||
| jwks_uri = "https://issuer.example.com/keys" | ||
|
|
||
| first = manager._get_jwk_client(jwks_uri) | ||
| second = manager._get_jwk_client(jwks_uri) | ||
|
|
||
| assert first is second | ||
| assert len(manager._cache) == 1 | ||
|
|
||
| def test_get_jwk_client_creates_distinct_entries_for_distinct_uris(self): | ||
| manager = _JwkClientManager() | ||
|
|
||
| first = manager._get_jwk_client("https://issuer-a.example.com/keys") | ||
| second = manager._get_jwk_client("https://issuer-b.example.com/keys") | ||
|
|
||
| assert first is not second | ||
| assert first.lock is not second.lock | ||
| assert len(manager._cache) == 2 | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_signing_key_calls_pyjwkclient_with_header_kid(self, monkeypatch): | ||
| manager = _JwkClientManager() | ||
| jwks_uri = "https://issuer.example.com/keys" | ||
| seen_kids = [] | ||
| expected_key = object() | ||
|
|
||
| def fake_get_signing_key(self, kid): | ||
| seen_kids.append(kid) | ||
| return expected_key | ||
|
|
||
| # Only mocked member: PyJWKClient.get_signing_key | ||
| monkeypatch.setattr(PyJWKClient, "get_signing_key", fake_get_signing_key) | ||
|
|
||
| key = await manager.get_signing_key(jwks_uri, {"kid": "kid-123"}) | ||
|
|
||
| assert key is expected_key | ||
| assert seen_kids == ["kid-123"] | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_signing_key_reuses_same_client_for_same_uri(self, monkeypatch): | ||
| manager = _JwkClientManager() | ||
| jwks_uri = "https://issuer.example.com/keys" | ||
| client_ids = [] | ||
|
|
||
| def fake_get_signing_key(self, kid): | ||
| client_ids.append(id(self)) | ||
| return {"kid": kid} | ||
|
|
||
| # Only mocked member: PyJWKClient.get_signing_key | ||
| monkeypatch.setattr(PyJWKClient, "get_signing_key", fake_get_signing_key) | ||
|
|
||
| await manager.get_signing_key(jwks_uri, {"kid": "kid-a"}) | ||
| await manager.get_signing_key(jwks_uri, {"kid": "kid-b"}) | ||
|
|
||
| assert client_ids[0] == client_ids[1] | ||
| assert len(manager._cache) == 1 | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_signing_key_serializes_concurrent_calls_per_uri( | ||
| self, monkeypatch | ||
| ): | ||
| manager = _JwkClientManager() | ||
| jwks_uri = "https://issuer.example.com/keys" | ||
|
|
||
| first_entered = threading.Event() | ||
| second_entered = threading.Event() | ||
| release_first = threading.Event() | ||
|
|
||
| def fake_get_signing_key(self, kid): | ||
| if kid == "kid-1": | ||
| first_entered.set() | ||
| if not release_first.wait(timeout=2): | ||
| raise TimeoutError("First call was not released in time.") | ||
| elif kid == "kid-2": | ||
| second_entered.set() | ||
| return {"kid": kid} | ||
|
|
||
| # Only mocked member: PyJWKClient.get_signing_key | ||
| monkeypatch.setattr(PyJWKClient, "get_signing_key", fake_get_signing_key) | ||
|
|
||
| first_task = asyncio.create_task( | ||
| manager.get_signing_key(jwks_uri, {"kid": "kid-1"}) | ||
| ) | ||
| await _wait_until_set(first_entered) | ||
|
|
||
| second_task = asyncio.create_task( | ||
| manager.get_signing_key(jwks_uri, {"kid": "kid-2"}) | ||
| ) | ||
|
|
||
| # If per-URI lock works, second call must not enter get_signing_key yet. | ||
| await asyncio.sleep(0.05) | ||
| assert not second_entered.is_set() | ||
|
|
||
| release_first.set() | ||
| results = await asyncio.gather(first_task, second_task) | ||
|
|
||
| assert results[0]["kid"] == "kid-1" | ||
| assert results[1]["kid"] == "kid-2" | ||
| assert second_entered.is_set() | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_signing_key_raises_key_error_when_header_has_no_kid(self): | ||
| manager = _JwkClientManager() | ||
|
|
||
| with pytest.raises(KeyError): | ||
| await manager.get_signing_key("https://issuer.example.com/keys", {}) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.