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
2 changes: 1 addition & 1 deletion .agents
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ repos:
- id: mypy
additional_dependencies:
- "pydantic"
- "pyjwt[crypto]"
- "types-requests"
- "types-cachetools"

Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)

---
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 58 additions & 10 deletions fastapi_oidc/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor the token algorithm when constructing alg-less JWKs

When a provider publishes a valid RSA JWK without the optional alg member and signs ID tokens with PS256, RS384, or another advertised RSA algorithm, PyJWKSet.from_dict() binds that key to RS256 by default. PyJWT then rejects every otherwise-valid token because its header algorithm does not match the key's bound algorithm, even when the algorithm appears in the discovery allow-list. Construct the selected PyJWK using the token header's allowed algorithm (or pass an unbound public key) so alg-less JWKs continue to support the provider's advertised signing algorithms.

Useful? React with 👍 / 👎.

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,
Expand Down Expand Up @@ -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
Expand All @@ -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
116 changes: 4 additions & 112 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand All @@ -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"]
Expand Down
Loading
Loading