diff --git a/pyhilo/api.py b/pyhilo/api.py index 2d111e2..531569f 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -12,7 +12,6 @@ from aiohttp import ClientSession from aiohttp.client_exceptions import ClientResponseError import backoff -from homeassistant.helpers import config_entry_oauth2_flow from pyhilo.const import ( ANDROID_CLIENT_ENDPOINT, @@ -67,7 +66,7 @@ def __init__( self, *, session: ClientSession, - oauth_session: config_entry_oauth2_flow.OAuth2Session, + oauth_session, request_retries: int = REQUEST_RETRY, log_traces: bool = False, ) -> None: @@ -98,7 +97,7 @@ async def async_create( cls, *, session: ClientSession, - oauth_session: config_entry_oauth2_flow.OAuth2Session, + oauth_session, request_retries: int = REQUEST_RETRY, log_traces: bool = False, ) -> API: @@ -385,7 +384,7 @@ async def _async_post_init(self) -> None: # instantiate differently self.websocket_devices = WebsocketClient(self.websocket_manager.devicehub) - # For backward compatibility during the transition to challengehub websocket + # For backward compatibility during the transition to challengehub websocket self.websocket = self.websocket_devices self.websocket_challenges = WebsocketClient(self.websocket_manager.challengehub) diff --git a/pyhilo/const.py b/pyhilo/const.py index 27e8c0c..6ea3751 100755 --- a/pyhilo/const.py +++ b/pyhilo/const.py @@ -3,12 +3,11 @@ from typing import Final import aiohttp -import homeassistant.core LOG: Final = logging.getLogger(__package__) DEFAULT_STATE_FILE: Final = "hilo_state.yaml" REQUEST_RETRY: Final = 9 -PYHILO_VERSION: Final = "2025.2.01" +PYHILO_VERSION: Final = "2025.2.02" # TODO: Find a way to keep previous line in sync with pyproject.toml automatically CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded" @@ -46,7 +45,7 @@ # Request constants -DEFAULT_USER_AGENT: Final = f"PyHilo/{PYHILO_VERSION} HomeAssistant/{homeassistant.core.__version__} aiohttp/{aiohttp.__version__} Python/{platform.python_version()}" +DEFAULT_USER_AGENT: Final = f"PyHilo/{PYHILO_VERSION} aiohttp/{aiohttp.__version__} Python/{platform.python_version()}" # NOTE(dvd): Not sure how to get new ones so I'm using the ones from my emulator diff --git a/pyhilo/oauth2.py b/pyhilo/oauth2.py index 33d4d63..a7c9816 100644 --- a/pyhilo/oauth2.py +++ b/pyhilo/oauth2.py @@ -1,4 +1,5 @@ """Custom OAuth2 implementation.""" + import base64 import hashlib import os @@ -8,14 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation -from pyhilo.const import ( - AUTH_AUTHORIZE, - AUTH_CHALLENGE_METHOD, - AUTH_CLIENT_ID, - AUTH_SCOPE, - AUTH_TOKEN, - DOMAIN, -) +from pyhilo.const import AUTH_AUTHORIZE, AUTH_CLIENT_ID, AUTH_TOKEN, DOMAIN +from pyhilo.oauth2helper import OAuth2Helper class AuthCodeWithPKCEImplementation(LocalOAuth2Implementation): # type: ignore[misc] @@ -34,8 +29,8 @@ def __init__( AUTH_AUTHORIZE, AUTH_TOKEN, ) - self._code_verifier = self._get_code_verifier() - self._code_challenge = self._get_code_challange(self._code_verifier) + + self.oauth_helper = OAuth2Helper() # ... Override AbstractOAuth2Implementation details @property @@ -46,32 +41,15 @@ def name(self) -> str: @property def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - return { - "scope": AUTH_SCOPE, - "code_challenge": self._code_challenge, - "code_challenge_method": AUTH_CHALLENGE_METHOD, - } + return self.oauth_helper.get_authorize_parameters() async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve the authorization code to tokens.""" return cast( dict, await self._token_request( - { - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - "code_verifier": self._code_verifier, - }, + self.oauth_helper.get_token_request_parameters( + external_data["code"], external_data["state"]["redirect_uri"] + ) ), ) - - # Ref : https://blog.sanghviharshit.com/reverse-engineering-private-api-oauth-code-flow-with-pkce/ - def _get_code_verifier(self) -> str: - code = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") - return re.sub("[^a-zA-Z0-9]+", "", code) - - def _get_code_challange(self, verifier: str) -> str: - sha_verifier = hashlib.sha256(verifier.encode("utf-8")).digest() - code = base64.urlsafe_b64encode(sha_verifier).decode("utf-8") - return code.replace("=", "") diff --git a/pyhilo/oauth2helper.py b/pyhilo/oauth2helper.py new file mode 100644 index 0000000..dab8c00 --- /dev/null +++ b/pyhilo/oauth2helper.py @@ -0,0 +1,47 @@ +import base64 +import hashlib +import os +import re +from typing import Any, cast + +from pyhilo.const import ( + AUTH_AUTHORIZE, + AUTH_CHALLENGE_METHOD, + AUTH_CLIENT_ID, + AUTH_SCOPE, + AUTH_TOKEN, +) + +class OAuth2Helper: + """Custom OAuth2 implementation.""" + + def __init__(self) -> None: + self._code_verifier = self._get_code_verifier() + self._code_challenge = self._get_code_challenge(self._code_verifier) + + # Ref : https://blog.sanghviharshit.com/reverse-engineering-private-api-oauth-code-flow-with-pkce/ + def _get_code_verifier(self) -> str: + code = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") + return re.sub("[^a-zA-Z0-9]+", "", code) + + def _get_code_challenge(self, verifier: str) -> str: + sha_verifier = hashlib.sha256(verifier.encode("utf-8")).digest() + code = base64.urlsafe_b64encode(sha_verifier).decode("utf-8") + return code.replace("=", "") + + def get_authorize_parameters(self): + return { + "scope": AUTH_SCOPE, + "code_challenge": self._code_challenge, + "code_challenge_method": AUTH_CHALLENGE_METHOD, + "response_type": "code", + "client_id": AUTH_CLIENT_ID, + } + + def get_token_request_parameters(self, code, redirect_uri): + return { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": self._code_verifier, + } diff --git a/pyproject.toml b/pyproject.toml index 0c8de27..4af4255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ exclude = ".venv/.*" [tool.poetry] name = "python-hilo" -version = "2025.2.1" +version = "2025.2.2" description = "A Python3, async interface to the Hilo API" readme = "README.md" authors = ["David Vallee Delisle "]