"""evohomeasync provides an async client for the v2 Resideo TCC API.""" from __future__ import annotations import base64 import logging from abc import ABC, abstractmethod from datetime import UTC, datetime as dt, timedelta as td from http import HTTPStatus from typing import TYPE_CHECKING, Any, Final, TypedDict import voluptuous as vol from _evohome.auth import AbstractAuth from _evohome.const import HEADERS_BASE, HEADERS_CRED, HINT_BAD_CREDS from _evohome.credentials import CredentialsManagerBase from _evohome.helpers import convert_keys_to_snake_case, redact from . import exceptions as exc if TYPE_CHECKING: import aiohttp from aiohttp.typedefs import StrOrURL from .schemas import ( TccAuthTokensResponseT as AuthTokenResponseT, # TCC is snake_case anyway ) class AccessTokenEntryT(TypedDict): """Dict for storing/restoring auth tokens to/from a cache.""" access_token: str access_token_expires: str # dt.isoformat() refresh_token: str _APPLICATION_ID: Final = base64.b64encode( b"4a231089-d2b6-41bd-a5eb-16a0a422b999:" b"1a15cdb8-42de-407b-add0-059f92c530cb" ).decode("utf-8") # fmt: off SZ_ACCESS_TOKEN: Final = "access_token" # noqa: S105 SZ_ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" # noqa: S105 SZ_EXPIRES_IN: Final = "expires_in" SZ_REFRESH_TOKEN: Final = "refresh_token" # noqa: S105 SCH_OAUTH_TOKEN: Final = vol.Schema( { vol.Required(SZ_ACCESS_TOKEN): vol.All(str, redact), vol.Required(SZ_EXPIRES_IN): int, # 1800 seconds vol.Required(SZ_REFRESH_TOKEN): vol.All(str, redact), vol.Required("token_type"): str, vol.Optional("scope"): str, # "EMEA-V1-Basic EMEA-V1-Anonymous" } ) CREDS_REFRESH_TOKEN: Final = { "grant_type": "refresh_token", "scope": "EMEA-V1-Basic EMEA-V1-Anonymous", "refresh_token": "", } CREDS_USER_PASSWORD: Final = { "grant_type": "password", "scope": "EMEA-V1-Basic EMEA-V1-Anonymous", # EMEA-V1-Get-Current-User-Account", "Username": "", "Password": "", } # POST authentication url (i.e. /Auth/OAuth/Token) URL_CRED: Final = "Auth/OAuth/Token" # GET/PUT resource url (e.g. /WebAPI/emea/api/v1/...) URL_BASE: Final = "WebAPI/emea/api/v1" class AbstractTokenManager(CredentialsManagerBase, ABC): """An ABC for managing the auth tokens used for HTTP authentication.""" _access_token: str _access_token_expires: dt _refresh_token: str = "" def __init__( self, client_id: str, secret: str, websession: aiohttp.ClientSession, /, logger: logging.Logger | None = None, _hostname: str | None = None, ) -> None: """Initialize the token manager.""" logger = logger or logging.getLogger(__name__) super().__init__( client_id, secret, websession, logger=logger, _hostname=_hostname ) self.clear_access_token() # initialise the attrs def clear_access_token(self) -> None: """Clear the auth tokens attrs (set to falsey state).""" self._access_token = "" self._access_token_expires = dt.min.replace(tzinfo=UTC) # don't need local TZ @property def access_token(self) -> str: """Return the access token.""" return self._access_token @property def access_token_expires(self) -> dt: """Return the expiration datetime of the access token.""" return self._access_token_expires @property def refresh_token(self) -> str: """Return the refresh token.""" return self._refresh_token async def get_access_token(self) -> str: # convenience wrapper """Return a valid access token. If required, fetch (and save) a new token via the vendor's web API. """ if not self.is_token_valid(): # although may be rejected for other reasons await self.fetch_access_token() await self.save_access_token() return self.access_token def is_token_valid(self) -> bool: """Return True if the access token is valid (the server may still reject it).""" return self._access_token_expires > dt.now(tz=UTC) + td(seconds=15) async def fetch_access_token(self) -> None: """Update the access token and save it to the store/cache.""" self._logger.debug("Fetching access_token...") if self._refresh_token: self._logger.debug(" - authenticating with the refresh_token") credentials = {SZ_REFRESH_TOKEN: self._refresh_token} try: await self._fetch_access_token(CREDS_REFRESH_TOKEN | credentials) except exc.AuthenticationFailedError as err: if err.status != HTTPStatus.BAD_REQUEST: # 400, e.g. invalid tokens raise self._logger.debug("Expired/invalid refresh_token") self._refresh_token = "" if not self._refresh_token: self._logger.debug(" - authenticating with client_id/secret") credentials = { # NOTE: the keys are case-sensitive: 'Username' and 'Password' "Username": self._client_id, "Password": self._secret, } try: # check here if the user credentials are invalid... await self._fetch_access_token(CREDS_USER_PASSWORD | credentials) except exc.AuthenticationFailedError as err: if "invalid_grant" not in err.message: raise self._logger.error(HINT_BAD_CREDS) # noqa: TRY400 # status will be: HTTPStatus.BAD_REQUEST, 400 raise exc.BadUserCredentialsError(f"{err}", status=err.status) from err self._was_authenticated = True # access_token is short-lived (but not safe to log refresh_token here)... self._logger.debug(f" - access_token = {redact(self.access_token)}") self._logger.debug(f" - access_token_expires = {self.access_token_expires}") async def _fetch_access_token(self, credentials: dict[str, str]) -> None: """Obtain an access token using the supplied credentials. The credentials are either a refresh token or the user's client_id/secret. Will raise AuthenticationFailedError if unable to obtain an access token. """ url = f"https://{self.hostname}/{URL_CRED}" response: AuthTokenResponseT = await self._post_access_token_request( url, headers=HEADERS_CRED | {"Authorization": "Basic " + _APPLICATION_ID}, data=credentials, # NOTE: is snake_case ) try: # the dict _should_ be the expected schema... self._logger.debug( f"POST {url}: {SCH_OAUTH_TOKEN(response)}" # secrets are redacted via validator ) except vol.Invalid as err: self._logger.warning(f"POST {url}: payload may be invalid: {err}") tokens: AuthTokenResponseT = convert_keys_to_snake_case(response) try: self._access_token = tokens[SZ_ACCESS_TOKEN] self._access_token_expires = dt.now(tz=UTC) + td( seconds=tokens[SZ_EXPIRES_IN] ) self._refresh_token = tokens[SZ_REFRESH_TOKEN] except (KeyError, TypeError) as err: raise exc.AuthenticationFailedError( f"Authenticator response is invalid: {response}: {err.__class__.__name__}: {err}" ) from err self._was_authenticated = True # i.e. the credentials are valid # if response.get(SZ_LATEST_EULA_ACCEPTED): async def _post_access_token_request( # dev/test wrapper (also typing) self, url: StrOrURL, /, **kwargs: Any ) -> AuthTokenResponseT: """Wrap the POST request to the vendor's TCC RESTful API.""" return await self._post_request(url, **kwargs) # type: ignore[return-value] @abstractmethod async def save_access_token(self) -> None: """Save the (serialized) access token (and expiry dtm, refresh token). Should ideally confirm the access token is valid before saving. """ def _import_access_token(self, tokens: AccessTokenEntryT) -> None: """Extract the token data from a (serialized) dictionary.""" self._access_token = tokens[SZ_ACCESS_TOKEN] self._access_token_expires = dt.fromisoformat(tokens[SZ_ACCESS_TOKEN_EXPIRES]) self._refresh_token = tokens[SZ_REFRESH_TOKEN] def _export_access_token(self) -> AccessTokenEntryT: """Convert the token data to a (serialized) dictionary.""" return { SZ_ACCESS_TOKEN_EXPIRES: self._access_token_expires.isoformat(), SZ_ACCESS_TOKEN: self._access_token, SZ_REFRESH_TOKEN: self._refresh_token, } class Auth(AbstractAuth): """A class for interacting with the v2 Resideo TCC API.""" def __init__( self, token_manager: AbstractTokenManager, websession: aiohttp.ClientSession, /, *, logger: logging.Logger | None = None, _hostname: str | None = None, ) -> None: """A class for interacting with the v2 Resideo TCC API.""" logger = logger or logging.getLogger(__name__) super().__init__(websession, logger=logger, _hostname=_hostname) self._access_token = token_manager.get_access_token self._url_base = f"https://{self.hostname}/{URL_BASE}" async def _headers(self, headers: dict[str, str] | None = None) -> dict[str, str]: """Ensure the authorization header has a valid access token.""" headers = HEADERS_BASE | (headers or {}) return headers | { "Authorization": "bearer " + await self._access_token(), }