"""Support for Alexa skill auth.""" import asyncio from asyncio import timeout from datetime import datetime, timedelta from http import HTTPStatus import json import logging from typing import Any import aiohttp from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util from .const import STORAGE_ACCESS_TOKEN, STORAGE_REFRESH_TOKEN from .diagnostics import async_redact_lwa_params _LOGGER = logging.getLogger(__name__) LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token" LWA_HEADERS = {"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"} PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300 STORAGE_KEY = "alexa_auth" STORAGE_VERSION = 1 STORAGE_EXPIRE_TIME = "expire_time" class Auth: """Handle authentication to send events to Alexa.""" def __init__(self, hass: HomeAssistant, client_id: str, client_secret: str) -> None: """Initialize the Auth class.""" self.hass = hass self.client_id = client_id self.client_secret = client_secret self._prefs: dict[str, Any] | None = None self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY) self._get_token_lock = asyncio.Lock() async def async_do_auth(self, accept_grant_code: str) -> str | None: """Do authentication with an AcceptGrant code.""" # access token not retrieved yet for the first time, so this should # be an access token request lwa_params: dict[str, str] = { "grant_type": "authorization_code", "code": accept_grant_code, CONF_CLIENT_ID: self.client_id, CONF_CLIENT_SECRET: self.client_secret, } _LOGGER.debug( "Calling LWA to get the access token (first time), with: %s", json.dumps(async_redact_lwa_params(lwa_params)), ) return await self._async_request_new_token(lwa_params) @callback def async_invalidate_access_token(self) -> None: """Invalidate access token.""" assert self._prefs is not None self._prefs[STORAGE_ACCESS_TOKEN] = None async def async_get_access_token(self) -> str | None: """Perform access token or token refresh request.""" async with self._get_token_lock: if self._prefs is None: await self.async_load_preferences() assert self._prefs is not None if self.is_token_valid(): _LOGGER.debug("Token still valid, using it") token: str = self._prefs[STORAGE_ACCESS_TOKEN] return token if self._prefs[STORAGE_REFRESH_TOKEN] is None: _LOGGER.debug("Token invalid and no refresh token available") return None lwa_params: dict[str, str] = { "grant_type": "refresh_token", "refresh_token": self._prefs[STORAGE_REFRESH_TOKEN], CONF_CLIENT_ID: self.client_id, CONF_CLIENT_SECRET: self.client_secret, } _LOGGER.debug("Calling LWA to refresh the access token") return await self._async_request_new_token(lwa_params) @callback def is_token_valid(self) -> bool: """Check if a token is already loaded and if it is still valid.""" assert self._prefs is not None if not self._prefs[STORAGE_ACCESS_TOKEN]: return False expire_time: datetime | None = dt_util.parse_datetime( self._prefs[STORAGE_EXPIRE_TIME] ) assert expire_time is not None preemptive_expire_time = expire_time - timedelta( seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS ) return dt_util.utcnow() < preemptive_expire_time async def _async_request_new_token(self, lwa_params: dict[str, str]) -> str | None: try: session = aiohttp_client.async_get_clientsession(self.hass) async with timeout(10): response = await session.post( LWA_TOKEN_URI, headers=LWA_HEADERS, data=lwa_params, allow_redirects=True, ) except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout calling LWA to get auth token") return None _LOGGER.debug("LWA response header: %s", response.headers) _LOGGER.debug("LWA response status: %s", response.status) if response.status != HTTPStatus.OK: _LOGGER.error("Error calling LWA to get auth token") return None response_json = await response.json() _LOGGER.debug("LWA response body : %s", async_redact_lwa_params(response_json)) access_token: str = response_json["access_token"] refresh_token: str = response_json["refresh_token"] expires_in: int = response_json["expires_in"] expire_time = dt_util.utcnow() + timedelta(seconds=expires_in) await self._async_update_preferences( access_token, refresh_token, expire_time.isoformat() ) return access_token async def async_load_preferences(self) -> None: """Load preferences with stored tokens.""" self._prefs = await self._store.async_load() if self._prefs is None: self._prefs = { STORAGE_ACCESS_TOKEN: None, STORAGE_REFRESH_TOKEN: None, STORAGE_EXPIRE_TIME: None, } async def _async_update_preferences( self, access_token: str, refresh_token: str, expire_time: str ) -> None: """Update user preferences.""" if self._prefs is None: await self.async_load_preferences() assert self._prefs is not None if access_token is not None: self._prefs[STORAGE_ACCESS_TOKEN] = access_token if refresh_token is not None: self._prefs[STORAGE_REFRESH_TOKEN] = refresh_token if expire_time is not None: self._prefs[STORAGE_EXPIRE_TIME] = expire_time await self._store.async_save(self._prefs)