mirror of https://github.com/home-assistant/core
177 lines
6.1 KiB
Python
177 lines
6.1 KiB
Python
"""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)
|