From bca24be0210e7467e3f6e87c47f7224bb7548bf0 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Sat, 4 Jan 2025 20:23:58 +0100 Subject: [PATCH 1/4] Rework token acquire/refresh logic --- .../polestar_api/pypolestar/auth.py | 133 +++++++++++------- .../polestar_api/pypolestar/polestar.py | 2 +- 2 files changed, 82 insertions(+), 53 deletions(-) diff --git a/custom_components/polestar_api/pypolestar/auth.py b/custom_components/polestar_api/pypolestar/auth.py index e940bcd..9ed13c0 100644 --- a/custom_components/polestar_api/pypolestar/auth.py +++ b/custom_components/polestar_api/pypolestar/auth.py @@ -101,70 +101,99 @@ def is_token_valid(self) -> bool: and self.token_expiry > datetime.now(tz=timezone.utc) ) - async def get_token(self, refresh=False) -> None: - """Get the token from Polestar.""" + async def get_token(self, refresh: bool = True, force: bool = False) -> None: + """Ensure we have a valid access token (still valid, refreshed or initial).""" if ( - not refresh - or self.token_expiry is None - or self.token_expiry < datetime.now(tz=timezone.utc) + not force + and self.access_token is not None + and self.token_expiry + and self.token_expiry > datetime.now(tz=timezone.utc) ): - if (code := await self._get_code()) is None: - return - - token_request = { - "grant_type": "authorization_code", - "client_id": OIDC_CLIENT_ID, - "code": code, - "redirect_uri": OIDC_REDIRECT_URI, - **( - { - "code_verifier": self.oidc_code_verifier, - } - if self.oidc_code_verifier - else {} - ), - } - - elif self.refresh_token: - token_request = { - "grant_type": "refresh_token", - "client_id": OIDC_CLIENT_ID, - "refresh_token": self.refresh_token, - } - else: + self.logger.debug("Token still valid until %s", self.token_expiry) + return + + if refresh and self.refresh_token: + try: + response = await self._token_refresh() + self._parse_token_response(response) + self.logger.debug("Token refreshed") + except PolestarAuthException: + self.logger.warning("Unable to refresh token, retry with code") + + try: + response = await self._authorization_code() + self._parse_token_response(response) + self.logger.debug("Initial token acquired") return + except PolestarAuthException as exc: + raise PolestarAuthException("Unable to acquire initial token") from exc + + def _parse_token_response(self, response: httpx.Response) -> None: + """Parse response from token endpoint.""" + + payload = response.json() + self.latest_call_code = response.status_code + + if "error" in payload: + self.logger.error("Token error: %s", payload) + raise PolestarAuthException("Token error", response.status_code) + + self.access_token = payload["access_token"] + self.refresh_token = payload["refresh_token"] + self.token_lifetime = payload["expires_in"] + self.token_expiry = datetime.now(tz=timezone.utc) + timedelta( + seconds=self.token_lifetime + ) + + self.logger.debug("Access token updated, valid until %s", self.token_expiry) + + async def _authorization_code(self) -> httpx.Response: + """Get initial token via authorization code.""" + + if (code := await self._get_code()) is None: + raise PolestarAuthException("Unable to get code") + + token_request = { + "grant_type": "authorization_code", + "client_id": OIDC_CLIENT_ID, + "code": code, + "redirect_uri": OIDC_REDIRECT_URI, + **( + {"code_verifier": self.oidc_code_verifier} + if self.oidc_code_verifier + else {} + ), + } self.logger.debug( "Call token endpoint with grant_type=%s", token_request["grant_type"] ) - try: - result = await self.client_session.post( - self.oidc_configuration["token_endpoint"], - data=token_request, - timeout=HTTPX_TIMEOUT, - ) - except Exception as exc: - self.latest_call_code = None - self.logger.error("Auth Token Error: %s", str(exc)) - raise PolestarAuthException("Error getting token") from exc + return await self.client_session.post( + self.oidc_configuration["token_endpoint"], + data=token_request, + timeout=HTTPX_TIMEOUT, + ) - payload = result.json() - self.latest_call_code = result.status_code + async def _token_refresh(self) -> httpx.Response: + """Refresh existing token.""" - try: - self.access_token = payload["access_token"] - self.refresh_token = payload["refresh_token"] - self.token_lifetime = payload["expires_in"] - self.token_expiry = datetime.now(tz=timezone.utc) + timedelta( - seconds=self.token_lifetime - ) - except KeyError as exc: - self.logger.error("Token response missing expected keys: %s", exc) - raise PolestarAuthException("Incomplete token response") from exc + token_request = { + "grant_type": "refresh_token", + "client_id": OIDC_CLIENT_ID, + "refresh_token": self.refresh_token, + } - self.logger.debug("Access token updated, valid until %s", self.token_expiry) + self.logger.debug( + "Call token endpoint with grant_type=%s", token_request["grant_type"] + ) + + return await self.client_session.post( + self.oidc_configuration["token_endpoint"], + data=token_request, + timeout=HTTPX_TIMEOUT, + ) async def _get_code(self) -> str | None: query_params = await self._get_resume_path() diff --git a/custom_components/polestar_api/pypolestar/polestar.py b/custom_components/polestar_api/pypolestar/polestar.py index 0892681..fef8fdf 100644 --- a/custom_components/polestar_api/pypolestar/polestar.py +++ b/custom_components/polestar_api/pypolestar/polestar.py @@ -197,7 +197,7 @@ async def get_ev_data(self, vin: str) -> None: try: if self.auth.need_token_refresh(): - await self.auth.get_token(refresh=True) + await self.auth.get_token() except PolestarAuthException as e: self.latest_call_code = 500 self.logger.warning("Auth Exception: %s", str(e)) From 62ea83465d5d438d7e40d441d4ff91234878b775 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Sat, 4 Jan 2025 21:20:13 +0100 Subject: [PATCH 2/4] Simplify logic --- .../polestar_api/pypolestar/auth.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/custom_components/polestar_api/pypolestar/auth.py b/custom_components/polestar_api/pypolestar/auth.py index 9ed13c0..d3802c7 100644 --- a/custom_components/polestar_api/pypolestar/auth.py +++ b/custom_components/polestar_api/pypolestar/auth.py @@ -115,26 +115,25 @@ async def get_token(self, refresh: bool = True, force: bool = False) -> None: if refresh and self.refresh_token: try: - response = await self._token_refresh() - self._parse_token_response(response) + await self._token_refresh() self.logger.debug("Token refreshed") except PolestarAuthException: self.logger.warning("Unable to refresh token, retry with code") try: - response = await self._authorization_code() - self._parse_token_response(response) + await self._authorization_code() self.logger.debug("Initial token acquired") return except PolestarAuthException as exc: raise PolestarAuthException("Unable to acquire initial token") from exc def _parse_token_response(self, response: httpx.Response) -> None: - """Parse response from token endpoint.""" + """Parse response from token endpoint and update token state.""" - payload = response.json() self.latest_call_code = response.status_code + payload = response.json() + if "error" in payload: self.logger.error("Token error: %s", payload) raise PolestarAuthException("Token error", response.status_code) @@ -148,7 +147,7 @@ def _parse_token_response(self, response: httpx.Response) -> None: self.logger.debug("Access token updated, valid until %s", self.token_expiry) - async def _authorization_code(self) -> httpx.Response: + async def _authorization_code(self) -> None: """Get initial token via authorization code.""" if (code := await self._get_code()) is None: @@ -170,13 +169,15 @@ async def _authorization_code(self) -> httpx.Response: "Call token endpoint with grant_type=%s", token_request["grant_type"] ) - return await self.client_session.post( + response = await self.client_session.post( self.oidc_configuration["token_endpoint"], data=token_request, timeout=HTTPX_TIMEOUT, ) - async def _token_refresh(self) -> httpx.Response: + self._parse_token_response(response) + + async def _token_refresh(self) -> None: """Refresh existing token.""" token_request = { @@ -189,12 +190,14 @@ async def _token_refresh(self) -> httpx.Response: "Call token endpoint with grant_type=%s", token_request["grant_type"] ) - return await self.client_session.post( + response = await self.client_session.post( self.oidc_configuration["token_endpoint"], data=token_request, timeout=HTTPX_TIMEOUT, ) + self._parse_token_response(response) + async def _get_code(self) -> str | None: query_params = await self._get_resume_path() From 260ad4ce106ab0ad5640f4b0ad86fab6eb1df9e6 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Sat, 4 Jan 2025 21:21:08 +0100 Subject: [PATCH 3/4] Remove refresh argument (always try to refresh) --- custom_components/polestar_api/pypolestar/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/polestar_api/pypolestar/auth.py b/custom_components/polestar_api/pypolestar/auth.py index d3802c7..ed90f5e 100644 --- a/custom_components/polestar_api/pypolestar/auth.py +++ b/custom_components/polestar_api/pypolestar/auth.py @@ -101,7 +101,7 @@ def is_token_valid(self) -> bool: and self.token_expiry > datetime.now(tz=timezone.utc) ) - async def get_token(self, refresh: bool = True, force: bool = False) -> None: + async def get_token(self, force: bool = False) -> None: """Ensure we have a valid access token (still valid, refreshed or initial).""" if ( @@ -113,7 +113,7 @@ async def get_token(self, refresh: bool = True, force: bool = False) -> None: self.logger.debug("Token still valid until %s", self.token_expiry) return - if refresh and self.refresh_token: + if self.refresh_token: try: await self._token_refresh() self.logger.debug("Token refreshed") From edef33dccdd26e70bcfdaf904dd8169033aa628c Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Sat, 4 Jan 2025 21:24:42 +0100 Subject: [PATCH 4/4] Improve error handling --- .../polestar_api/pypolestar/auth.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/custom_components/polestar_api/pypolestar/auth.py b/custom_components/polestar_api/pypolestar/auth.py index ed90f5e..8e74f87 100644 --- a/custom_components/polestar_api/pypolestar/auth.py +++ b/custom_components/polestar_api/pypolestar/auth.py @@ -117,14 +117,16 @@ async def get_token(self, force: bool = False) -> None: try: await self._token_refresh() self.logger.debug("Token refreshed") - except PolestarAuthException: - self.logger.warning("Unable to refresh token, retry with code") + except Exception as exc: + self.logger.warning( + "Failed to refresh token, retry with code", exc_info=exc + ) try: await self._authorization_code() self.logger.debug("Initial token acquired") return - except PolestarAuthException as exc: + except Exception as exc: raise PolestarAuthException("Unable to acquire initial token") from exc def _parse_token_response(self, response: httpx.Response) -> None: @@ -138,12 +140,16 @@ def _parse_token_response(self, response: httpx.Response) -> None: self.logger.error("Token error: %s", payload) raise PolestarAuthException("Token error", response.status_code) - self.access_token = payload["access_token"] - self.refresh_token = payload["refresh_token"] - self.token_lifetime = payload["expires_in"] - self.token_expiry = datetime.now(tz=timezone.utc) + timedelta( - seconds=self.token_lifetime - ) + try: + self.access_token = payload["access_token"] + self.refresh_token = payload["refresh_token"] + self.token_lifetime = payload["expires_in"] + self.token_expiry = datetime.now(tz=timezone.utc) + timedelta( + seconds=self.token_lifetime + ) + except KeyError as exc: + self.logger.error("Token response missing key: %s", exc) + raise PolestarAuthException("Token response missing key") from exc self.logger.debug("Access token updated, valid until %s", self.token_expiry) @@ -174,7 +180,7 @@ async def _authorization_code(self) -> None: data=token_request, timeout=HTTPX_TIMEOUT, ) - + response.raise_for_status() self._parse_token_response(response) async def _token_refresh(self) -> None: @@ -195,7 +201,7 @@ async def _token_refresh(self) -> None: data=token_request, timeout=HTTPX_TIMEOUT, ) - + response.raise_for_status() self._parse_token_response(response) async def _get_code(self) -> str | None: