Skip to content

Commit

Permalink
[DOP-23149] Replace python-jose with authlib.jose
Browse files Browse the repository at this point in the history
  • Loading branch information
dolfinus committed Jan 9, 2025
1 parent 06df16e commit a090150
Show file tree
Hide file tree
Showing 9 changed files with 58 additions and 143 deletions.
1 change: 1 addition & 0 deletions docs/changelog/next_release/97.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Replace outdated ``python-jose`` dependency with ``authlib.jose``, to fix security issues.
2 changes: 1 addition & 1 deletion horizon/backend/settings/auth/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class JWTSettings(BaseModel):
"""
Algorithm used for signing JWT tokens.
See `python-jose <https://python-jose.readthedocs.io/en/latest/jws/index.html#supported-algorithms>`_
See `authlib <https://docs.authlib.org/en/latest/specs/rfc7518.html>`_
documentation.
""",
),
Expand Down
24 changes: 12 additions & 12 deletions horizon/backend/utils/jwt.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
# SPDX-FileCopyrightText: 2023-2025 MTS PJSC
# SPDX-License-Identifier: Apache-2.0

from jose import ExpiredSignatureError, JWTError, jwt
from authlib.jose import JsonWebToken
from authlib.jose.errors import ExpiredTokenError, JoseError

from horizon.commons.exceptions import AuthorizationError


def sign_jwt(payload: dict, secret_key: str, security_algorithm: str) -> str:
jwt = JsonWebToken([security_algorithm])
return jwt.encode(
payload,
secret_key,
algorithm=security_algorithm,
)
header={"alg": security_algorithm},
payload=payload,
key=secret_key,
).decode("utf-8")


def decode_jwt(token: str, secret_key: str, security_algorithm: str) -> dict:
try:
result = jwt.decode(
token,
secret_key,
algorithms=[security_algorithm],
)
result = JsonWebToken([security_algorithm]).decode(token, key=secret_key)
if "exp" not in result:
raise ExpiredSignatureError("Missing expiration time in token")
raise ExpiredTokenError("Missing expiration time in token")

result.validate()

return result
except JWTError as e:
except JoseError as e:
raise AuthorizationError("Invalid token") from e
36 changes: 28 additions & 8 deletions horizon/client/auth/access_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@

from __future__ import annotations

from authlib.jose import JWTClaims, jwt
from authlib.jose.errors import BadSignatureError, ExpiredTokenError
from authlib.oauth2.auth import OAuth2Token as AuthlibToken
from jose import ExpiredSignatureError, jwt
from pydantic import AnyHttpUrl, BaseModel, validator
from typing_extensions import Literal

Expand Down Expand Up @@ -37,23 +38,42 @@ def patch_session(self, session: Session) -> Session:
if session.token:
return session

# https://github.com/lepture/authlib/issues/600
token_decoded = jwt.decode(self.token, key="NONE", options={"verify_signature": False})
# AuthlibToken expiration is optional, and JWT token is not parsed.
# We have to extract expiration time manually.
claims = self._parse_token(self.token)
session.token = AuthlibToken.from_dict(
{
"access_token": self.token,
"token_type": "Bearer",
"expires_at": token_decoded["exp"],
"expires_at": claims["exp"],
},
)
return session

def fetch_token_kwargs(self, base_url: AnyHttpUrl) -> dict[str, str]:
return {}

@classmethod
def _parse_token(cls, token) -> JWTClaims:
# As client don't have private key used for signing JWT, this method will always raise this exception
# https://github.com/lepture/authlib/issues/600
try:
jwt.decode(token, key="")
except BadSignatureError as e:
token_decoded = e.result.payload
claims = JWTClaims(
header=token_decoded,
payload=token_decoded,
)

if "exp" not in claims:
raise ExpiredTokenError("Missing expiration time in token")

claims.validate()
return claims

@validator("token")
def _validate_token(cls, value):
token_decoded = jwt.decode(value, key="NONE", options={"verify_signature": False})
if "exp" not in token_decoded:
raise ExpiredSignatureError("Missing expiration time in token")
def _validate_access_token(cls, value):
# AuthlibToken doesn't perform any validation, so we have to
cls._parse_token(value)
return value
118 changes: 8 additions & 110 deletions poetry.lock

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

5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "data-horizon"
version = "1.0.3"
version = "1.1.0"
license = "Apache-2.0"
description = "Horizon REST API + client"
authors = ["DataOps.ETL <[email protected]>"]
Expand Down Expand Up @@ -57,7 +57,6 @@ typing-extensions = [
{version = ">=4.0.0,<4.8.0", python = "3.7"},
{version = ">=4.0.0", python = ">=3.8"},
]
python-jose = {version = "*", extras=["cryptography"]}
fastapi = [
{version = "^0.103.2", optional = true, python = "3.7"},
{version = ">=0.103.0", optional = true, python = ">=3.8"},
Expand Down Expand Up @@ -141,6 +140,7 @@ backend = [
"pydantic-settings",
"devtools",
"passlib",
"authlib",
]
postgres = [
"asyncpg",
Expand Down Expand Up @@ -214,7 +214,6 @@ wemake-python-styleguide = [
{version = "^1.0.0", python = ">=3.10"},
]
flake8-pyproject = {version = "^1.2.3", python = ">=3.8"}
types-python-jose = {version = "^3.3.4", python = ">=3.8"}
types-passlib = {version = "^1.7.7", python = ">=3.8"}
types-pyyaml = [
{version = "6.0.12.12", python = "3.7"},
Expand Down
8 changes: 4 additions & 4 deletions tests/test_client/test_auth/test_access_token.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
from __future__ import annotations

import pytest
from jose import ExpiredSignatureError, JWTError
from authlib.jose.errors import ExpiredTokenError, JoseError

from horizon.client.auth import AccessToken

pytestmark = [pytest.mark.client_sync, pytest.mark.client]


def test_access_token_constructor_expired(access_token_expired: AccessToken):
with pytest.raises(ExpiredSignatureError):
with pytest.raises(ExpiredTokenError):
AccessToken(token=access_token_expired)


def test_access_token_constructor_no_expiration_time(access_token_no_expiration_time: AccessToken):
with pytest.raises(ExpiredSignatureError):
with pytest.raises(ExpiredTokenError):
AccessToken(token=access_token_no_expiration_time)


def test_access_token_constructor_malformed(access_token_malformed: AccessToken):
with pytest.raises(JWTError):
with pytest.raises(JoseError):
AccessToken(token=access_token_malformed)
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,5 @@ def test_sync_client_delete_hwm_missing(


def test_sync_client_delete_hwm_malformed(sync_client: HorizonClientSync):
with pytest.raises(
requests.exceptions.HTTPError,
match="422 Client Error: Unprocessable Entity for url: http://localhost:8000/v1/hwm/",
):
with pytest.raises(requests.exceptions.HTTPError, match="422 Client Error"):
sync_client.delete_hwm("")
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,5 @@ def test_sync_client_get_hwm_missing(new_hwm: HWM, sync_client: HorizonClientSyn

def test_sync_client_get_hwm_with_wrong_params(sync_client: HorizonClientSync):
# hwm_id has wrong type, raw exception is raised
with pytest.raises(requests.exceptions.HTTPError, match="422 Client Error: Unprocessable Entity"):
with pytest.raises(requests.exceptions.HTTPError, match="422 Client Error"):
sync_client.get_hwm("abc")

0 comments on commit a090150

Please sign in to comment.