Skip to content

Commit

Permalink
Fall back to jwks_url if no jwks_uri found
Browse files Browse the repository at this point in the history
- 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.
- `OpenIDConfigurationTypeDef` was removed, you can use `ConfigurationTypeDef` instead.
- Security updates to libraries (aiohttp, idna, cryptography).
- Updated pre-commit hooks.
- Improvements to README.
  • Loading branch information
joakimnordling committed Aug 6, 2024
1 parent 2e07935 commit 219b7d6
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 148 deletions.
14 changes: 7 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
Expand All @@ -21,27 +21,27 @@ repos:
args: ["--fix=crlf"]
files: \.bat$
- repo: https://github.com/pycqa/isort
rev: 5.12.0
rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 23.1.0
rev: 24.8.0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
rev: 7.1.1
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
rev: v3.1.0
hooks:
- id: prettier
- repo: https://github.com/PyCQA/bandit
rev: 1.7.4
rev: 1.7.9
hooks:
- id: bandit
exclude: /tests
- repo: https://github.com/twu/skjold
rev: v0.6.1
rev: v0.6.2
hooks:
- id: skjold
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

## [0.8.0] - 2024-08-07

### 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.

### Removed

- `OpenIDConfigurationTypeDef` was removed, you can use `ConfigurationTypeDef` instead.

### Changed

- Security updates to libraries (aiohttp, idna, cryptography).
- Updated pre-commit hooks.
- Improvements to README.

## [0.7.0] - 2024-01-17

### Added
Expand Down Expand Up @@ -98,7 +115,8 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

- Everything for the initial release

[unreleased]: https://github.com/ioxiocom/pyjwt-key-fetcher/compare/0.7.0...HEAD
[unreleased]: https://github.com/ioxiocom/pyjwt-key-fetcher/compare/0.8.0...HEAD
[0.8.0]: https://github.com/ioxiocom/pyjwt-key-fetcher/compare/0.7.0...0.8.0
[0.7.0]: https://github.com/ioxiocom/pyjwt-key-fetcher/compare/0.6.0...0.7.0
[0.6.0]: https://github.com/ioxiocom/pyjwt-key-fetcher/compare/0.5.0...0.6.0
[0.5.0]: https://github.com/ioxiocom/pyjwt-key-fetcher/compare/0.4.0...0.5.0
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ Async library to fetch JWKs for JWT tokens.

This library is intended to be used together with
[PyJWT](https://pyjwt.readthedocs.io/en/stable/) to automatically verify keys signed by
OpenID Connect providers. It retrieves the `iss` (issuer) and the `kid` (key ID) from
the JWT, fetches the `.well-known/openid-configuration` from the issuer to find out the
`jwks_uri` and fetches that to find the right key.
for example OpenID Connect providers. It retrieves the `iss` (issuer) and the `kid` (key
ID) from the JWT, fetches the configuration, typically from
`.well-known/openid-configuration` (can be overridden) from the issuer to find out the
`jwks_uri` (or `jwks_url`) and fetches that to find the right key.

This should give similar ability to verify keys as for example
[https://jwt.io/](https://jwt.io/), where you can just paste in a token, and it will
Expand Down Expand Up @@ -95,7 +96,8 @@ The minimum interval for checking for new keys can for now not be adjusted.
You can change from which path the configuration is loaded from the issuer (`iss`). By
default, the configuration is assumed to be an OpenID Connect configuration and to be
loaded from `/.well-known/openid-configuration`. As long as the configuration contains a
`jwks_uri` you can change the configuration to be loaded from a custom path.
`jwks_uri` or a `jwks_url` you can change the configuration to be loaded from a custom
path.

You can override the config path when creating the `AsyncKeyFetcher` like this:

Expand All @@ -107,7 +109,7 @@ AsyncKeyFetcher(config_path="/.well-known/dataspace/party-configuration.json")

If you use an issuer that does not provide a configuration (they are for example missing
the `/.well-known/openid-configuration`), you can create a static configuration to use
for that issuer instead and in it specify the `jwks_uri` like this:
for that issuer instead and in it specify the `jwks_uri` (or `jwks_url`) like this:

```python
AsyncKeyFetcher(
Expand Down
246 changes: 131 additions & 115 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyjwt_key_fetcher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
from pyjwt_key_fetcher.fetcher import AsyncKeyFetcher
from pyjwt_key_fetcher.http_client import DefaultHTTPClient
from pyjwt_key_fetcher.key import Key
from pyjwt_key_fetcher.provider import OpenIDConfigurationTypeDef
from pyjwt_key_fetcher.provider import ConfigurationTypeDef
4 changes: 2 additions & 2 deletions pyjwt_key_fetcher/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pyjwt_key_fetcher.errors import JWTFormatError, JWTInvalidIssuerError
from pyjwt_key_fetcher.http_client import DefaultHTTPClient, HTTPClient
from pyjwt_key_fetcher.key import Key
from pyjwt_key_fetcher.provider import OpenIDConfigurationTypeDef, Provider
from pyjwt_key_fetcher.provider import ConfigurationTypeDef, Provider


class AsyncKeyFetcher:
Expand All @@ -17,7 +17,7 @@ def __init__(
cache_ttl: int = 3600,
cache_maxsize: int = 32,
config_path: str = "/.well-known/openid-configuration",
static_issuer_config: Optional[Dict[str, OpenIDConfigurationTypeDef]] = None,
static_issuer_config: Optional[Dict[str, ConfigurationTypeDef]] = None,
) -> None:
"""
Initialize a new AsyncKeyFetcher.
Expand Down
36 changes: 27 additions & 9 deletions pyjwt_key_fetcher/provider.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Callable, Dict, Mapping, Optional, TypedDict
from typing import Any, Callable, Dict, Mapping, Optional, TypedDict, Union
from uuid import uuid4

import aiocache # type: ignore
Expand Down Expand Up @@ -37,21 +37,32 @@ def key_builder(
return key


class OpenIDConfigurationTypeDef(TypedDict):
class JwksUriConfigurationTypeDef(TypedDict):
"""
Type definition for the OpenID configuration values relevant to JWT validation.
Type definition for an OpenID Connect compatible configuration with a jwks_uri.
"""

jwks_uri: str


class JwksUrlConfigurationTypeDef(TypedDict):
"""
Type definition for a configuration using jwks_url instead of jwks_uri.
"""

jwks_url: str


ConfigurationTypeDef = Union[JwksUriConfigurationTypeDef, JwksUrlConfigurationTypeDef]


class Provider:
def __init__(
self,
iss: str,
http_client: HTTPClient,
config_path: str = "/.well-known/openid-configuration",
static_config: Optional[OpenIDConfigurationTypeDef] = None,
static_config: Optional[ConfigurationTypeDef] = None,
) -> None:
self.iss = iss
self.http_client = http_client
Expand Down Expand Up @@ -85,17 +96,24 @@ async def get_configuration(self) -> Mapping[str, Any]:

async def _get_jwks_uri(self) -> str:
"""
Retrieve the uri to JWKs.
Retrieve the uri/url to JWKs.
:return: The uri to the JWKs.
:return: The uri/url to the JWKs.
:raise JWTHTTPFetchError: If there's a problem fetching the data.
:raise JWTProviderConfigError: If the config doesn't contain "jwks_uri".
:raise JWTProviderConfigError: If the config doesn't contain "jwks_uri" or
"jwks_url".
"""
conf = await self.get_configuration()
jwks_uri: str
try:
jwks_uri: str = conf["jwks_uri"]
jwks_uri = conf["jwks_uri"]
except KeyError as e:
raise JWTProviderConfigError("Missing 'jwks_uri' in configuration") from e
try:
jwks_uri = conf["jwks_url"]
except KeyError:
raise JWTProviderConfigError(
"Missing 'jwks_uri' and 'jwks_url' in configuration"
) from e
return jwks_uri

@aiocache.cached(ttl=300, key_builder=key_builder)
Expand Down
7 changes: 5 additions & 2 deletions pyjwt_key_fetcher/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,14 @@ def __init__(
provider: MockProvider,
config_path: str = "/.well-known/openid-configuration",
jwks_path: str = "/.well-known/jwks",
jwks_uri_field: str = "jwks_uri",
) -> None:
self.providers = {provider.iss: provider}
self.get_jwks = MagicMock(wraps=self.get_jwks) # type: ignore
self.get_configuration = MagicMock(wraps=self.get_configuration) # type: ignore
self.config_path = config_path
self.jwks_path = jwks_path
self.jwks_uri_field = jwks_uri_field

async def get_json(self, url: str) -> Dict[str, Any]:
"""
Expand Down Expand Up @@ -159,7 +161,7 @@ def get_jwks(self, provider: MockProvider) -> Dict[str, Any]:

def get_configuration(self, provider: MockProvider) -> Dict[str, Any]:
return {
"jwks_uri": provider.iss + self.jwks_path,
self.jwks_uri_field: provider.iss + self.jwks_path,
}


Expand All @@ -169,9 +171,10 @@ async def _create(
valid_issuers=None,
iss: str = "https://example.com",
aud: str = "default_audience",
jwks_uri_field: str = "jwks_uri",
):
provider = MockProvider(iss=iss, aud=aud)
http_client = MockHTTPClient(provider=provider)
http_client = MockHTTPClient(provider=provider, jwks_uri_field=jwks_uri_field)
fetcher = AsyncKeyFetcher(valid_issuers=valid_issuers, http_client=http_client)
return provider, fetcher, http_client

Expand Down
28 changes: 23 additions & 5 deletions pyjwt_key_fetcher/tests/test_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@
from pyjwt_key_fetcher.errors import JWTInvalidIssuerError, JWTKeyNotFoundError


@pytest.mark.parametrize(
"jwks_uri_field",
[
"jwks_uri",
"jwks_url",
],
)
@pytest.mark.asyncio
async def test_fetching_key(create_provider_fetcher_and_client):
provider, fetcher, client = await create_provider_fetcher_and_client()
async def test_fetching_key(create_provider_fetcher_and_client, jwks_uri_field):
provider, fetcher, client = await create_provider_fetcher_and_client(
jwks_uri_field=jwks_uri_field,
)

token = provider.create_token()
key_entry = await fetcher.get_key(token)
Expand Down Expand Up @@ -117,14 +126,23 @@ async def test_issuer_validation(create_provider_fetcher_and_client, create_prov
assert client.get_jwks.call_count == 1


@pytest.mark.parametrize(
"jwks_uri_field",
[
"jwks_uri",
"jwks_url",
],
)
@pytest.mark.asyncio
async def test_static_issuer_config():
async def test_static_issuer_config(jwks_uri_field):
issuer = "https://valid.example.com"

fetcher = pyjwt_key_fetcher.AsyncKeyFetcher(
static_issuer_config={issuer: {"jwks_uri": f"{issuer}/.well-known/jwks.json"}}
static_issuer_config={
issuer: {jwks_uri_field: f"{issuer}/.well-known/jwks.json"}
}
)
provider = fetcher._get_provider(issuer)

provider_config = await provider.get_configuration()
assert provider_config == {"jwks_uri": f"{issuer}/.well-known/jwks.json"}
assert provider_config == {jwks_uri_field: f"{issuer}/.well-known/jwks.json"}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ packages = [{ include = "pyjwt_key_fetcher", from = "." }]
[tool.poetry.dependencies]
python = "^3.8"
PyJWT = { version = "^2.8.0", extras = ["crypto"] }
aiohttp = { version = "^3.9.1", extras = ["speedups"] }
aiohttp = {version = "^3.10.1", extras = ["speedups"]}
cachetools = "^5.3.2"
aiocache = "^0.12.2"

Expand Down

0 comments on commit 219b7d6

Please sign in to comment.