From 674c342a81662c4db8442914cda4ea71eb4c8cb7 Mon Sep 17 00:00:00 2001 From: Christiaan Goossens <9487666+christiaangoossens@users.noreply.github.com> Date: Fri, 31 Oct 2025 10:16:45 +0100 Subject: [PATCH] Migrate to joserfc, remove python-jose (#150) --- .../auth_oidc/tools/oidc_client.py | 69 +++++++------------ pyproject.toml | 13 ++-- uv.lock | 61 ++-------------- 3 files changed, 37 insertions(+), 106 deletions(-) diff --git a/custom_components/auth_oidc/tools/oidc_client.py b/custom_components/auth_oidc/tools/oidc_client.py index 59f88cc..1d3413a 100644 --- a/custom_components/auth_oidc/tools/oidc_client.py +++ b/custom_components/auth_oidc/tools/oidc_client.py @@ -9,7 +9,7 @@ import ssl from typing import Optional from functools import partial import aiohttp -from jose import jwt, jwk +from joserfc import jwt, jwk, jws, errors as joserfc_errors from homeassistant.core import HomeAssistant from .types import UserDetails @@ -433,9 +433,7 @@ class OIDCClient: """Fetches JWKS.""" return await self.discovery_class.fetch_jwks(jwks_uri) - async def _parse_id_token( - self, id_token: str, access_token: str | None - ) -> Optional[dict]: + async def _parse_id_token(self, id_token: str) -> Optional[dict]: """Parses the ID token into a dict containing token contents.""" if self.discovery_document is None: self.discovery_document = await self._fetch_discovery_document() @@ -445,7 +443,8 @@ class OIDCClient: try: # Obtain the id_token header - unverified_header = jwt.get_unverified_header(id_token) + token_obj = jws.extract_compact(id_token.encode()) + unverified_header = token_obj.protected if not unverified_header: _LOGGER.warning("Could not get header from received id_token.") return None @@ -474,7 +473,7 @@ class OIDCClient: ) raise OIDCIdTokenSigningAlgorithmInvalid() - jwk_obj = jwk.construct( + jwk_obj = jwk.import_key( { "kty": "oct", "k": base64.urlsafe_b64encode( @@ -507,9 +506,9 @@ class OIDCClient: signing_key["alg"] = alg # Construct the JWK from the RSA key - jwk_obj = jwk.construct(signing_key) + jwk_obj = jwk.import_key(signing_key) - # Verify the token + # Decode the token, decode does not verify it decoded_token = jwt.decode( id_token, jwk_obj, @@ -518,48 +517,31 @@ class OIDCClient: # according to JWS [JWS] using the algorithm specified in the JWT # alg Header Parameter. algorithms=[self.id_token_signing_alg], + ) + + # Create Claims Registry for validation + id_token_validator = jwt.JWTClaimsRegistry( + leeway=5, # OpenID Connect Core 1.0 Section 3.1.3.7.3 # The Client MUST validate that the aud (audience) Claim contains # its client_id value registered at the Issuer identified by the # iss (issuer) Claim as an audience. - audience=self.client_id, + aud={"essential": True, "value": self.client_id}, # OpenID Connect Core 1.0 Section 3.1.3.7.2 # The Issuer Identifier for the OpenID Provider MUST exactly # match the value of the iss (issuer) Claim. - issuer=self.discovery_document["issuer"], - access_token=access_token, - options={ - # Verify everything if present - "verify_signature": True, - "verify_aud": True, - "verify_iat": True, - "verify_exp": True, - "verify_nbf": True, - "verify_iss": True, - "verify_sub": True, - "verify_jti": True, - "verify_at_hash": True, - # OpenID Connect Core 1.0 Section 3.1.3.7.3 - "require_aud": True, - # OpenID Connect Core 1.0 Section 3.1.3.7.10 - "require_iat": True, - # OpenID Connect Core 1.0 Section 3.1.3.7.9 - "require_exp": True, - # OpenID Connect Core 1.0 Section 3.1.3.7.2 - "require_iss": True, - # We need the sub as it's used to identify the user - "require_sub": True, - # Other values, not required. - "require_nbf": False, - "require_jti": False, - "require_at_hash": False, - "leeway": 5, - }, + iss={"essential": True, "value": self.discovery_document["issuer"]}, + # OpenID Connect Core 1.0 Section 3.1.3.7.9 + # OpenID Connect Core 1.0 Section 3.1.3.7.10 + # No need to specify exp, nbf, iat, they are in here by default + sub={"essential": True}, ) - return decoded_token - except jwt.JWTError as e: - _LOGGER.warning("JWT Verification failed: %s", e) + id_token_validator.validate(decoded_token.claims) + return decoded_token.claims + + except joserfc_errors.JoseError as e: + _LOGGER.warning("JWT verification failed: %s", e) return None async def async_get_authorization_url(self, redirect_uri: str) -> Optional[str]: @@ -692,11 +674,9 @@ class OIDCClient: ) id_token = token_response.get("id_token") - access_token = token_response.get("access_token") # Parse the id token to obtain the relevant details - # Access token is supplied to check at_hash if present - id_token = await self._parse_id_token(id_token, access_token) + id_token = await self._parse_id_token(id_token) if id_token is None: _LOGGER.warning("ID token could not be parsed!") @@ -710,6 +690,7 @@ class OIDCClient: _LOGGER.warning("Nonce mismatch!") return None + access_token = token_response.get("access_token") data = await self.parse_user_details(id_token, access_token) # Log which details were obtained for debugging diff --git a/pyproject.toml b/pyproject.toml index fe6a9b0..23e8e6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,23 +7,22 @@ authors = [ ] license = "MIT" dependencies = [ - "python-jose~=3.5.0", "aiofiles~=25.1", "jinja2~=3.1", "bcrypt~=4.2", - "joserfc>=1.3.4", + "joserfc~=1.4.0", ] readme = "README.md" requires-python = "~=3.13.7" [dependency-groups] dev = [ - "homeassistant~=2025.8", + "homeassistant~=2025.10", "pylint~=4.0", - "pytest>=8.4.2", - "pytest-asyncio>=1.2.0", - "pytest-cov>=7.0.0", - "pytest-homeassistant-custom-component>=0.13.286", + "pytest~=8.4.2", + "pytest-asyncio~=1.2.0", + "pytest-cov~=7.0.0", + "pytest-homeassistant-custom-component~=0.13.286", "ruff~=0.12", ] diff --git a/uv.lock b/uv.lock index 065ca2d..03d13c9 100644 --- a/uv.lock +++ b/uv.lock @@ -686,18 +686,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, ] -[[package]] -name = "ecdsa" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, -] - [[package]] name = "envs" version = "1.4" @@ -904,7 +892,6 @@ dependencies = [ { name = "bcrypt" }, { name = "jinja2" }, { name = "joserfc" }, - { name = "python-jose" }, ] [package.dev-dependencies] @@ -923,18 +910,17 @@ requires-dist = [ { name = "aiofiles", specifier = "~=25.1" }, { name = "bcrypt", specifier = "~=4.2" }, { name = "jinja2", specifier = "~=3.1" }, - { name = "joserfc", specifier = ">=1.3.4" }, - { name = "python-jose", specifier = "~=3.5.0" }, + { name = "joserfc", specifier = "~=1.4.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "homeassistant", specifier = "~=2025.8" }, + { name = "homeassistant", specifier = "~=2025.10" }, { name = "pylint", specifier = "~=4.0" }, - { name = "pytest", specifier = ">=8.4.2" }, - { name = "pytest-asyncio", specifier = ">=1.2.0" }, - { name = "pytest-cov", specifier = ">=7.0.0" }, - { name = "pytest-homeassistant-custom-component", specifier = ">=0.13.286" }, + { name = "pytest", specifier = "~=8.4.2" }, + { name = "pytest-asyncio", specifier = "~=1.2.0" }, + { name = "pytest-cov", specifier = "~=7.0.0" }, + { name = "pytest-homeassistant-custom-component", specifier = "~=0.13.286" }, { name = "ruff", specifier = "~=0.12" }, ] @@ -1457,15 +1443,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/48/8a0acb683d1fee78b966b15e78143b673154abb921061515254fb573aacd/psutil_home_assistant-0.0.1-py3-none-any.whl", hash = "sha256:35a782e93e23db845fc4a57b05df9c52c2d5c24f5b233bd63b01bae4efae3c41", size = 6300, upload-time = "2022-08-25T14:28:38.083Z" }, ] -[[package]] -name = "pyasn1" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, -] - [[package]] name = "pycares" version = "4.11.0" @@ -1892,20 +1869,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-jose" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ecdsa" }, - { name = "pyasn1" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, -] - [[package]] name = "python-slugify" version = "8.0.4" @@ -2006,18 +1969,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" version = "0.14.2"