diff --git a/.agents b/.agents index 20ab619..426423f 100644 --- a/.agents +++ b/.agents @@ -306,7 +306,7 @@ open docs/_build/index.html ### Production Dependencies - `fastapi` (≥0.61.0) - Web framework - `pydantic` (≥2.0.0) - Data validation -- `python-jose[cryptography]` (≥3.2.0) - JWT handling +- `pyjwt[crypto]` (≥2.13.0) - JWT handling - `requests` (≥2.24.0) - HTTP client - `cachetools` (≥4.1.1) - Caching diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 37361b8..327e18f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,7 @@ repos: - id: mypy additional_dependencies: - "pydantic" + - "pyjwt[crypto]" - "types-requests" - "types-cachetools" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8504a8a..159aa8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Replaced `python-jose` with `PyJWT` for token verification.** `python-jose` + pulls in `ecdsa`, `rsa` and `pyasn1`, which have carried unfixable or + slow-to-fix security advisories (notably the Minerva timing attack in + `python-ecdsa`, which upstream will not fix). PyJWT with the `crypto` extra + needs only `cryptography`. The public `get_auth()` API and the + `authenticate_user` dependency it returns are unchanged; invalid tokens still + raise `HTTPException(401)`. +- Signing keys are now selected from the provider's JWKS by the token's `kid` + header. A JWKS with a single key is still accepted for tokens without a `kid`. +- The minimum PyJWT version is 2.13.0, which includes fixes for algorithm + allow-list bypass and key-confusion issues when verifying with JWK keys. + +### Removed +- `python-jose[cryptography]` runtime dependency and the `types-python-jose` + dev dependency. + ## [0.1.0] - 2026-06-14 ### Added diff --git a/README.md b/README.md index 2d5de3a..be526fe 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Acknowledgments - Built with [FastAPI](https://fastapi.tiangolo.com/) -- Token validation via [python-jose](https://github.com/mpdavis/python-jose) +- Token validation via [PyJWT](https://github.com/jpadilla/pyjwt) - Type validation with [Pydantic](https://pydantic-docs.helpmanual.io/) --- diff --git a/SECURITY.md b/SECURITY.md index fcc9414..5d24c04 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -173,7 +173,7 @@ No formal security audits have been conducted yet. We welcome community security ## Cryptographic Dependencies This library relies on: -- `python-jose[cryptography]` - JWT handling and verification +- `pyjwt[crypto]` - JWT handling and verification - `cryptography` - Cryptographic primitives These are well-established, actively maintained libraries with strong security track records. diff --git a/fastapi_oidc/auth.py b/fastapi_oidc/auth.py index 54c5867..88a6223 100644 --- a/fastapi_oidc/auth.py +++ b/fastapi_oidc/auth.py @@ -16,23 +16,66 @@ def test_auth(authenticated_user: AuthenticatedUser = Depends(authenticate_user) """ from collections.abc import Iterable +from typing import Any from typing import Callable from typing import Optional from typing import Type +import jwt from fastapi import Depends from fastapi import HTTPException from fastapi.security import OpenIdConnect -from jose import ExpiredSignatureError -from jose import JWTError -from jose import jwt -from jose.exceptions import JWTClaimsError +from jwt import PyJWK +from jwt import PyJWKSet +from jwt.exceptions import InvalidKeyError +from jwt.exceptions import PyJWTError from fastapi_oidc import discovery from fastapi_oidc.exceptions import TokenSpecificationError from fastapi_oidc.types import IDToken +def _select_signing_key(keys: Any, id_token: str) -> Any: + """Pick the key that should verify ``id_token`` out of ``keys``. + + ``keys`` is normally the JWKS document served by the auth server's + ``jwks_uri`` (a dict with a ``"keys"`` list). In that case the key is chosen + by matching the token header's ``kid``. A JWKS with a single key is accepted + for tokens that carry no ``kid``. Anything that is not a JWKS document (a PEM + string, an already-built :class:`jwt.PyJWK`, ...) is handed to PyJWT + unchanged. + + Args: + keys: The JWKS document, or a single key understood by :func:`jwt.decode`. + id_token: The compact-serialized JWT being verified. + + Returns: + A key acceptable by :func:`jwt.decode`. + + Raises: + jwt.exceptions.PyJWTError: If no usable key matches the token. + """ + if not isinstance(keys, dict) or "keys" not in keys: + return keys + + jwk_set = PyJWKSet.from_dict(keys) + kid = jwt.get_unverified_header(id_token).get("kid") + + if kid is not None: + try: + return jwk_set[kid] + except KeyError as err: + raise InvalidKeyError(f"No signing key found for kid={kid!r}") from err + + if len(jwk_set.keys) == 1: + key: PyJWK = jwk_set.keys[0] + return key + + raise InvalidKeyError( + "Token has no 'kid' header and the JWKS contains more than one key" + ) + + def get_auth( *, client_id: str, @@ -82,6 +125,12 @@ def get_auth( discover = discovery.configure(cache_ttl=signature_cache_ttl) + # PyJWT accepts a str or a container of str for the issuer. Materialise + # arbitrary iterables (generators, sets, ...) so membership checks work. + expected_issuer: str | list[str] = ( + issuer if isinstance(issuer, str) else list(issuer) + ) + def authenticate_user(auth_header: str = Depends(oauth2_scheme)) -> IDToken: """Validate and parse OIDC ID token against issuer in config. Note this function caches the signatures and algorithms of the issuing server @@ -99,22 +148,21 @@ def authenticate_user(auth_header: str = Depends(oauth2_scheme)) -> IDToken: """ id_token = auth_header.split(" ")[-1] OIDC_discoveries = discover.auth_server(base_url=base_authorization_server_uri) - key = discover.public_keys(OIDC_discoveries) + keys = discover.public_keys(OIDC_discoveries) algorithms = discover.signing_algos(OIDC_discoveries) try: + key = _select_signing_key(keys, id_token) token = jwt.decode( id_token, key, - algorithms, + algorithms=algorithms, audience=audience if audience else client_id, - issuer=issuer, - # Disabled at_hash check since we aren't using the access token - options={"verify_at_hash": False}, + issuer=expected_issuer, ) return token_type.model_validate(token) - except (ExpiredSignatureError, JWTError, JWTClaimsError) as err: + except PyJWTError as err: raise HTTPException(status_code=401, detail=f"Unauthorized: {err}") return authenticate_user diff --git a/poetry.lock b/poetry.lock index 1e2614f..bdecbf2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -692,25 +692,6 @@ files = [ {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] -[[package]] -name = "ecdsa" -version = "0.19.2" -description = "ECDSA cryptographic signature library (pure python)" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" -groups = ["main"] -files = [ - {file = "ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399"}, - {file = "ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930"}, -] - -[package.dependencies] -six = ">=1.9.0" - -[package.extras] -gmpy = ["gmpy"] -gmpy2 = ["gmpy2"] - [[package]] name = "exceptiongroup" version = "1.2.2" @@ -1385,18 +1366,6 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" -[[package]] -name = "pyasn1" -version = "0.6.3" -description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, - {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, -] - [[package]] name = "pycparser" version = "2.22" @@ -1586,13 +1555,14 @@ version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] files = [ {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] [package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] @@ -1687,30 +1657,6 @@ files = [ [package.extras] cli = ["click (>=5.0)"] -[[package]] -name = "python-jose" -version = "3.5.0" -description = "JOSE implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, - {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, -] - -[package.dependencies] -cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryptography\""} -ecdsa = "!=0.15" -pyasn1 = ">=0.5.0" -rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" - -[package.extras] -cryptography = ["cryptography (>=3.4.0)"] -pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] -pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] -test = ["pytest", "pytest-cov"] - [[package]] name = "pytokens" version = "0.4.1" @@ -1879,33 +1825,6 @@ files = [ [package.dependencies] roman-numerals = "4.1.0" -[[package]] -name = "rsa" -version = "4.9" -description = "Pure-Python RSA implementation" -optional = false -python-versions = ">=3.6,<4" -groups = ["main"] -files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -2160,33 +2079,6 @@ files = [ {file = "types_cachetools-6.2.0.20260408.tar.gz", hash = "sha256:0d8ae2dd5ba0b4cfe6a55c34396dd0415f1be07d0033d84781cdc4ed9c2ebc6b"}, ] -[[package]] -name = "types-pyasn1" -version = "0.6.0.20260408" -description = "Typing stubs for pyasn1" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "types_pyasn1-0.6.0.20260408-py3-none-any.whl", hash = "sha256:ee7fbd98bce61193c5d4f8f7812fa53cddc5b8cc5ceb9fcda6eea539947c6d6b"}, - {file = "types_pyasn1-0.6.0.20260408.tar.gz", hash = "sha256:32dc90927adbe504fd2eee83ae30cf5ef934e5db0d1d94886071fed47eb50c8c"}, -] - -[[package]] -name = "types-python-jose" -version = "3.5.0.20260408" -description = "Typing stubs for python-jose" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "types_python_jose-3.5.0.20260408-py3-none-any.whl", hash = "sha256:968d8a8eac1ff9da249d6335a2bb9f82288d59ba23afe91fcc2662eb9f485e2a"}, - {file = "types_python_jose-3.5.0.20260408.tar.gz", hash = "sha256:3f8dccdc327bfffea7a81084ea1cea722fa499f13c1d04f7978b491dd36e0cf1"}, -] - -[package.dependencies] -types-pyasn1 = "*" - [[package]] name = "types-requests" version = "2.33.0.20260518" @@ -2281,7 +2173,7 @@ description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.1" groups = ["dev"] -markers = "platform_python_implementation != \"PyPy\" and sys_platform != \"win32\" and sys_platform != \"cygwin\"" +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" files = [ {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, @@ -2555,4 +2447,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "d3ad12284437302efdfb71b30059d73e3bf242efd42707b989cb60481561862c" +content-hash = "c7cafe752a934a101ce1d490271f8191ce7701bf1696f880f0488150691dea4b" diff --git a/pyproject.toml b/pyproject.toml index d2b36a6..fc0a2cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,20 +16,18 @@ fastapi = ">= 0.61.0" pydantic = ">= 2.0.0" cachetools = ">= 4.1.1" requests = ">= 2.24.0" -python-jose = {extras = ["cryptography"], version = ">= 3.2.0"} +pyjwt = {extras = ["crypto"], version = ">= 2.13.0"} [tool.poetry.group.dev.dependencies] pytest = ">=8,<10" pytest-cov = ">=5,<8" black = ">=24,<27" pylint = "^3.0.0" -pyjwt = "^2.0.0" sphinx = "^8.0.0" mypy = ">=1.11,<3.0" types-cachetools = "^6.0" types-requests = "^2.25.0" pre-commit = "^3.0.0" -types-python-jose = "^3.3" httpx = "^0.28.1" uvicorn = {extras = ["standard"], version = "^0.49.0"} @@ -41,7 +39,7 @@ build-backend = "poetry.core.masonry.api" profile = "black" force_single_line = "True" known_first_party = [] -known_third_party = ["cachetools", "cryptography", "fastapi", "jose", "jwt", "pydantic", "pytest", "requests"] +known_third_party = ["cachetools", "cryptography", "fastapi", "jwt", "pydantic", "pytest", "requests"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py index 0b727c9..6c2d9fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm FIXTURES_DIRECTORY = Path(__file__).parent / "fixtures" @@ -136,9 +137,53 @@ def token_without_audience(private_key, no_audience_config, test_email) -> str: @pytest.fixture def mock_discovery(oidc_discovery, public_key): + """Discovery stub whose ``public_keys`` returns a bare PEM key.""" + class functions: auth_server = lambda **_: oidc_discovery public_keys = lambda _: public_key signing_algos = lambda x: x["id_token_signing_alg_values_supported"] return lambda *args, **kwargs: functions + + +KID = "TheHydrogenSonata" + + +@pytest.fixture +def jwks(key): + """A JWKS document (what a real ``jwks_uri`` serves) containing the test key.""" + jwk = RSAAlgorithm.to_jwk(key.public_key(), as_dict=True) + return {"keys": [{**jwk, "kid": KID, "alg": "RS256", "use": "sig"}]} + + +@pytest.fixture +def mock_discovery_jwks(oidc_discovery, jwks): + """Discovery stub whose ``public_keys`` returns a JWKS document.""" + + class functions: + auth_server = lambda **_: oidc_discovery + public_keys = lambda _: jwks + signing_algos = lambda x: x["id_token_signing_alg_values_supported"] + + return lambda *args, **kwargs: functions + + +@pytest.fixture +def make_token(private_key, config_w_aud, test_email): + """Factory for RS256 tokens valid for ``config_w_aud``; accepts JWT header overrides.""" + + def _make(headers=None, **claims): + now = int(time.time()) + payload = { + "aud": config_w_aud["audience"], + "iss": config_w_aud["issuer"], + "email": test_email, + "sub": "foo", + "exp": now + 30, + "iat": now, + **claims, + } + return jwt.encode(payload, private_key, algorithm="RS256", headers=headers) + + return _make diff --git a/tests/test_auth.py b/tests/test_auth.py index f84c4b4..ab06b0d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,5 @@ # type: ignore +import jwt import pytest from fastapi_oidc import auth @@ -69,3 +70,118 @@ class CustomToken(IDToken): custom_token: CustomToken = authenticate_user(auth_header=f"Bearer {token}") assert custom_token.custom_field == "OnlySlightlyBent" + + +def test__authenticate_user_selects_jwks_key_by_kid( + monkeypatch, mock_discovery_jwks, make_token, config_w_aud, test_email +): + """A real provider serves a JWKS; the key is chosen by the token's kid.""" + from tests.conftest import KID + + monkeypatch.setattr(auth.discovery, "configure", mock_discovery_jwks) + + authenticate_user = auth.get_auth(**config_w_aud) + id_token = authenticate_user( + auth_header=f"Bearer {make_token(headers={'kid': KID})}" + ) + + assert id_token.email == test_email + + +def test__authenticate_user_accepts_single_key_jwks_without_kid( + monkeypatch, mock_discovery_jwks, make_token, config_w_aud, test_email +): + monkeypatch.setattr(auth.discovery, "configure", mock_discovery_jwks) + + authenticate_user = auth.get_auth(**config_w_aud) + id_token = authenticate_user(auth_header=f"Bearer {make_token()}") + + assert id_token.email == test_email + + +def test__authenticate_user_rejects_unknown_kid( + monkeypatch, mock_discovery_jwks, make_token, config_w_aud +): + from fastapi import HTTPException + + monkeypatch.setattr(auth.discovery, "configure", mock_discovery_jwks) + + authenticate_user = auth.get_auth(**config_w_aud) + + with pytest.raises(HTTPException) as exc_info: + authenticate_user(auth_header=f"Bearer {make_token(headers={'kid': 'nope'})}") + + assert exc_info.value.status_code == 401 + assert "kid" in exc_info.value.detail + + +def test__authenticate_user_rejects_multi_key_jwks_without_kid( + monkeypatch, oidc_discovery, jwks, make_token, config_w_aud +): + from fastapi import HTTPException + + two_keys = {"keys": [jwks["keys"][0], {**jwks["keys"][0], "kid": "second"}]} + + class functions: + auth_server = lambda **_: oidc_discovery + public_keys = lambda _: two_keys + signing_algos = lambda x: x["id_token_signing_alg_values_supported"] + + monkeypatch.setattr(auth.discovery, "configure", lambda *a, **k: functions) + + authenticate_user = auth.get_auth(**config_w_aud) + + with pytest.raises(HTTPException) as exc_info: + authenticate_user(auth_header=f"Bearer {make_token()}") + + assert exc_info.value.status_code == 401 + + +def test__authenticate_user_rejects_wrong_signing_key( + monkeypatch, mock_discovery_jwks, config_w_aud, test_email +): + """A token signed by a key that is not in the JWKS is rejected with 401.""" + import time + + from cryptography.hazmat.primitives.asymmetric import rsa + from fastapi import HTTPException + + from tests.conftest import KID + + monkeypatch.setattr(auth.discovery, "configure", mock_discovery_jwks) + + rogue_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = int(time.time()) + forged = jwt.encode( + { + "aud": config_w_aud["audience"], + "iss": config_w_aud["issuer"], + "email": test_email, + "sub": "foo", + "exp": now + 30, + "iat": now, + }, + rogue_key, + algorithm="RS256", + headers={"kid": KID}, + ) + + authenticate_user = auth.get_auth(**config_w_aud) + + with pytest.raises(HTTPException) as exc_info: + authenticate_user(auth_header=f"Bearer {forged}") + + assert exc_info.value.status_code == 401 + + +def test__authenticate_user_accepts_issuer_generator( + monkeypatch, mock_discovery, make_token, config_w_aud, test_email +): + """Any iterable of issuers works, not just lists.""" + monkeypatch.setattr(auth.discovery, "configure", mock_discovery) + + config = {**config_w_aud, "issuer": (i for i in ["other", config_w_aud["issuer"]])} + authenticate_user = auth.get_auth(**config) + id_token = authenticate_user(auth_header=f"Bearer {make_token()}") + + assert id_token.email == test_email