"""Package to communicate with the authentication API.""" from __future__ import annotations import asyncio from functools import lru_cache, partial import logging import random from typing import TYPE_CHECKING, Any import boto3 import botocore from botocore.exceptions import BotoCoreError, ClientError, EndpointConnectionError import pycognito from pycognito.exceptions import ForceChangePasswordException, MFAChallengeException from ..const import MESSAGE_AUTH_FAIL from ..exceptions import CloudError from ..utils import expiration_from_token, seconds_as_dhms, utcnow if TYPE_CHECKING: from .. import Cloud, _ClientT _LOGGER = logging.getLogger(__name__) class Unauthenticated(CloudError): """Raised when authentication failed.""" class MFARequired(CloudError): """Raised when MFA is required.""" _mfa_tokens: dict[str, Any] def __init__(self, mfa_tokens: dict[str, Any]) -> None: """Initialize MFA required error.""" super().__init__("MFA required.") self._mfa_tokens = mfa_tokens @property def mfa_tokens(self) -> dict[str, Any]: """Return MFA tokens.""" return self._mfa_tokens class InvalidTotpCode(CloudError): """Raised when the TOTP code is invalid.""" class UserNotFound(CloudError): """Raised when a user is not found.""" class UserExists(CloudError): """Raised when a username already exists.""" class UserNotConfirmed(CloudError): """Raised when a user has not confirmed email yet.""" class CloudConnectionError(CloudError): """Raised when unable to connect to the cloud.""" class PasswordChangeRequired(CloudError): """Raised when a password change is required.""" # https://github.com/PyCQA/pylint/issues/1085 # pylint: disable=useless-super-delegation def __init__(self, message: str = "Password change required.") -> None: """Initialize a password change required error.""" super().__init__(message) class UnknownError(CloudError): """Raised when an unknown error occurs.""" AWS_EXCEPTIONS: dict[str, type[CloudError]] = { "CodeMismatchException": InvalidTotpCode, "UserNotFoundException": UserNotFound, "UserNotConfirmedException": UserNotConfirmed, "UsernameExistsException": UserExists, "NotAuthorizedException": Unauthenticated, "PasswordResetRequiredException": PasswordChangeRequired, "EndpointConnectionError": CloudConnectionError, } class CognitoAuth: """Handle cloud auth.""" def __init__(self, cloud: Cloud[_ClientT]) -> None: """Configure the auth api.""" self.cloud = cloud self._refresh_task: asyncio.Task | None = None self._session: boto3.Session | None = None self._request_lock = asyncio.Lock() cloud.iot.register_on_connect(self.on_connect) cloud.iot.register_on_disconnect(self.on_disconnect) async def _async_handle_token_refresh(self) -> None: """Handle Cloud access token refresh.""" def _sleep_time() -> int: """Generate token refresh sleep time.""" if expiration_time := expiration_from_token(self.cloud.access_token): seconds_left = expiration_time - int(utcnow().timestamp()) if (suggestion := seconds_left - random.randint(10, 610)) > 1: return suggestion # If we don't have a valid token, or it is about to expire, # refresh it in a random time between 40 minutes and 1 hour. return random.randint(2400, 3600) while True: try: sleep_time = _sleep_time() _LOGGER.debug( "Sleeping for %s before refreshing token", seconds_as_dhms(sleep_time), ) await asyncio.sleep(sleep_time) await self.async_renew_access_token() except CloudError as err: _LOGGER.error("Can't refresh cloud token: %s", err) except asyncio.CancelledError: # Task is canceled, stop it. break async def on_connect(self) -> None: """When the instance is connected.""" self._refresh_task = asyncio.create_task(self._async_handle_token_refresh()) async def on_disconnect(self) -> None: """When the instance is disconnected.""" if self._refresh_task is not None: self._refresh_task.cancel() async def async_register( self, email: str, password: str, *, client_metadata: Any | None = None, ) -> None: """Register a new account.""" try: async with self._request_lock: cognito = await self.cloud.run_executor( self._create_cognito_client, ) await self.cloud.run_executor( partial( cognito.register, email.lower(), password, client_metadata=client_metadata, ), ) except (ClientError, EndpointConnectionError) as err: raise _map_aws_exception(err) from err except BotoCoreError as err: raise UnknownError from err async def async_resend_email_confirm(self, email: str) -> None: """Resend email confirmation.""" try: async with self._request_lock: cognito = await self.cloud.run_executor( partial(self._create_cognito_client, username=email), ) await self.cloud.run_executor( partial( cognito.client.resend_confirmation_code, Username=email, ClientId=cognito.client_id, ), ) except (ClientError, EndpointConnectionError) as err: raise _map_aws_exception(err) from err except BotoCoreError as err: raise UnknownError from err async def async_forgot_password(self, email: str) -> None: """Initialize forgotten password flow.""" try: async with self._request_lock: cognito = await self.cloud.run_executor( partial(self._create_cognito_client, username=email), ) await self.cloud.run_executor(cognito.initiate_forgot_password) except (ClientError, EndpointConnectionError) as err: raise _map_aws_exception(err) from err except BotoCoreError as err: raise UnknownError from err async def async_login( self, email: str, password: str, *, check_connection: bool = False, ) -> None: """Log user in and fetch certificate.""" try: async with self._request_lock: assert not self.cloud.is_logged_in, "Cannot login if already logged in." cognito: pycognito.Cognito = await self.cloud.run_executor( partial(self._create_cognito_client, username=email), ) async with asyncio.timeout(30): await self.cloud.run_executor( partial(cognito.authenticate, password=password), ) if check_connection: await self.cloud.ensure_not_connected( access_token=cognito.access_token ) task = await self.cloud.update_token( cognito.id_token, cognito.access_token, cognito.refresh_token, ) if task: await task except MFAChallengeException as err: raise MFARequired(err.get_tokens()) from err except ForceChangePasswordException as err: raise PasswordChangeRequired from err except (ClientError, EndpointConnectionError) as err: raise _map_aws_exception(err) from err except BotoCoreError as err: raise UnknownError from err async def async_login_verify_totp( self, email: str, code: str, mfa_tokens: dict[str, Any], *, check_connection: bool = False, ) -> None: """Log user in and fetch certificate if MFA is required.""" try: async with self._request_lock: assert not self.cloud.is_logged_in, ( "Cannot verify TOTP if already logged in." ) cognito: pycognito.Cognito = await self.cloud.run_executor( partial(self._create_cognito_client, username=email), ) async with asyncio.timeout(30): await self.cloud.run_executor( partial( cognito.respond_to_software_token_mfa_challenge, code=code, mfa_tokens=mfa_tokens, ), ) if check_connection: await self.cloud.ensure_not_connected( access_token=cognito.access_token ) task = await self.cloud.update_token( cognito.id_token, cognito.access_token, cognito.refresh_token, ) if task: await task except (ClientError, EndpointConnectionError) as err: raise _map_aws_exception(err) from err except BotoCoreError as err: raise UnknownError from err async def async_check_token(self) -> None: """Check that the token is valid and renew if necessary.""" async with self._request_lock: cognito = await self._async_authenticated_cognito() if not cognito.check_token(renew=False): return try: await self._async_renew_access_token() except (Unauthenticated, UserNotFound) as err: _LOGGER.error("Unable to refresh token: %s", err) self.cloud.client.user_message( "cloud_subscription_expired", "Home Assistant Cloud", MESSAGE_AUTH_FAIL, ) # Don't await it because it could cancel this task asyncio.create_task(self.cloud.logout()) raise async def async_renew_access_token(self) -> None: """Renew access token.""" async with self._request_lock: await self._async_renew_access_token() async def _async_renew_access_token(self) -> None: """Renew access token internals. Does not consume lock. """ cognito = await self._async_authenticated_cognito() try: await self.cloud.run_executor(cognito.renew_access_token) await self.cloud.update_token(cognito.id_token, cognito.access_token) except (ClientError, EndpointConnectionError) as err: raise _map_aws_exception(err) from err except BotoCoreError as err: raise UnknownError from err async def _async_authenticated_cognito(self) -> pycognito.Cognito: """Return an authenticated cognito instance.""" if self.cloud.access_token is None or self.cloud.refresh_token is None: raise Unauthenticated("No authentication found") return await self.cloud.run_executor( partial( self._create_cognito_client, access_token=self.cloud.access_token, refresh_token=self.cloud.refresh_token, ), ) def _create_cognito_client(self, **kwargs: Any) -> pycognito.Cognito: """Create a new cognito client. NOTE: This will do I/O """ if self._session is None: self._session = boto3.session.Session() return _cached_cognito( user_pool_id=self.cloud.user_pool_id, client_id=self.cloud.cognito_client_id, user_pool_region=self.cloud.region, botocore_config=botocore.config.Config(signature_version=botocore.UNSIGNED), session=self._session, **kwargs, ) def _map_aws_exception(err: ClientError | BotoCoreError) -> CloudError: """Map AWS exception to our exceptions.""" if isinstance(err, BotoCoreError): return AWS_EXCEPTIONS.get(err.__class__.__name__, UnknownError)( err.kwargs.get("error", str(err)) ) return AWS_EXCEPTIONS.get(err.response["Error"]["Code"], UnknownError)( err.response["Error"]["Message"] ) @lru_cache(maxsize=2) def _cached_cognito( user_pool_id: str, client_id: str, user_pool_region: str, botocore_config: Any, session: Any, **kwargs: Any, ) -> pycognito.Cognito: """Create a cached cognito client. NOTE: This will do I/O """ return pycognito.Cognito( user_pool_id=user_pool_id, client_id=client_id, user_pool_region=user_pool_region, botocore_config=botocore_config, session=session, **kwargs, )