Home Assistant Unofficial Reference 2024.12.1
api.py
Go to the documentation of this file.
1 """Client library for talking to Google APIs."""
2 
3 from __future__ import annotations
4 
5 import datetime
6 import logging
7 from typing import Any, cast
8 
9 import aiohttp
10 from gcal_sync.auth import AbstractAuth
11 from oauth2client.client import (
12  Credentials,
13  DeviceFlowInfo,
14  FlowExchangeError,
15  OAuth2DeviceCodeError,
16  OAuth2WebServerFlow,
17 )
18 
19 from homeassistant.components.application_credentials import AuthImplementation
20 from homeassistant.config_entries import ConfigEntry
21 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
22 from homeassistant.helpers import config_entry_oauth2_flow
23 from homeassistant.helpers.event import (
24  async_track_point_in_utc_time,
25  async_track_time_interval,
26 )
27 from homeassistant.util import dt as dt_util
28 
29 from .const import CONF_CALENDAR_ACCESS, DEFAULT_FEATURE_ACCESS, FeatureAccess
30 
31 _LOGGER = logging.getLogger(__name__)
32 
33 EVENT_PAGE_SIZE = 100
34 EXCHANGE_TIMEOUT_SECONDS = 60
35 DEVICE_AUTH_CREDS = "creds"
36 
37 
38 class OAuthError(Exception):
39  """OAuth related error."""
40 
41 
43  """Error with an invalid credential that does not support device auth."""
44 
45 
47  """OAuth implementation that supports both Web Auth (base class) and Device Auth."""
48 
49  async def async_resolve_external_data(self, external_data: Any) -> dict:
50  """Resolve a Google API Credentials object to Home Assistant token."""
51  if DEVICE_AUTH_CREDS not in external_data:
52  # Assume the Web Auth flow was used, so use the default behavior
53  return await super().async_resolve_external_data(external_data)
54  creds: Credentials = external_data[DEVICE_AUTH_CREDS]
55  delta = creds.token_expiry.replace(tzinfo=datetime.UTC) - dt_util.utcnow()
56  _LOGGER.debug(
57  "Token expires at %s (in %s)", creds.token_expiry, delta.total_seconds()
58  )
59  return {
60  "access_token": creds.access_token,
61  "refresh_token": creds.refresh_token,
62  "scope": " ".join(creds.scopes),
63  "token_type": "Bearer",
64  "expires_in": delta.total_seconds(),
65  }
66 
67 
68 class DeviceFlow:
69  """OAuth2 device flow for exchanging a code for an access token."""
70 
71  def __init__(
72  self,
73  hass: HomeAssistant,
74  oauth_flow: OAuth2WebServerFlow,
75  device_flow_info: DeviceFlowInfo,
76  ) -> None:
77  """Initialize DeviceFlow."""
78  self._hass_hass = hass
79  self._oauth_flow_oauth_flow = oauth_flow
80  self._device_flow_info: DeviceFlowInfo = device_flow_info
81  self._exchange_task_unsub_exchange_task_unsub: CALLBACK_TYPE | None = None
82  self._timeout_unsub_timeout_unsub: CALLBACK_TYPE | None = None
83  self._listener_listener: CALLBACK_TYPE | None = None
84  self._creds_creds: Credentials | None = None
85 
86  @property
87  def verification_url(self) -> str:
88  """Return the verification url that the user should visit to enter the code."""
89  return self._device_flow_info.verification_url # type: ignore[no-any-return]
90 
91  @property
92  def user_code(self) -> str:
93  """Return the code that the user should enter at the verification url."""
94  return self._device_flow_info.user_code # type: ignore[no-any-return]
95 
96  @callback
98  self,
99  update_callback: CALLBACK_TYPE,
100  ) -> None:
101  """Invoke the update callback when the exchange finishes or on timeout."""
102  self._listener_listener = update_callback
103 
104  @property
105  def creds(self) -> Credentials | None:
106  """Return result of exchange step or None on timeout."""
107  return self._creds_creds
108 
109  def async_start_exchange(self) -> None:
110  """Start the device auth exchange flow polling."""
111  _LOGGER.debug("Starting exchange flow")
112  max_timeout = dt_util.utcnow() + datetime.timedelta(
113  seconds=EXCHANGE_TIMEOUT_SECONDS
114  )
115  # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
116  # object without tzinfo. For the comparison below to work, it needs one.
117  user_code_expiry = self._device_flow_info.user_code_expiry.replace(
118  tzinfo=datetime.UTC
119  )
120  expiration_time = min(user_code_expiry, max_timeout)
121 
123  self._hass_hass,
124  self._async_poll_attempt_async_poll_attempt,
125  datetime.timedelta(seconds=self._device_flow_info.interval),
126  )
128  self._hass_hass, self._async_timeout_async_timeout, expiration_time
129  )
130 
131  async def _async_poll_attempt(self, now: datetime.datetime) -> None:
132  _LOGGER.debug("Attempting OAuth code exchange")
133  try:
134  self._creds_creds = await self._hass_hass.async_add_executor_job(self._exchange_exchange)
135  except FlowExchangeError:
136  _LOGGER.debug("Token not yet ready; trying again later")
137  return
138  self._finish_finish()
139 
140  def _exchange(self) -> Credentials:
141  return self._oauth_flow_oauth_flow.step2_exchange(device_flow_info=self._device_flow_info)
142 
143  @callback
144  def _async_timeout(self, now: datetime.datetime) -> None:
145  _LOGGER.debug("OAuth token exchange timeout")
146  self._finish_finish()
147 
148  @callback
149  def _finish(self) -> None:
150  if self._exchange_task_unsub_exchange_task_unsub:
151  self._exchange_task_unsub_exchange_task_unsub()
152  if self._timeout_unsub_timeout_unsub:
153  self._timeout_unsub_timeout_unsub()
154  if self._listener_listener:
155  self._listener_listener()
156 
157 
158 def get_feature_access(config_entry: ConfigEntry) -> FeatureAccess:
159  """Return the desired calendar feature access."""
160  if config_entry.options and CONF_CALENDAR_ACCESS in config_entry.options:
161  return FeatureAccess[config_entry.options[CONF_CALENDAR_ACCESS]]
162  return DEFAULT_FEATURE_ACCESS
163 
164 
166  hass: HomeAssistant, client_id: str, client_secret: str, access: FeatureAccess
167 ) -> DeviceFlow:
168  """Create a new Device flow."""
169  oauth_flow = OAuth2WebServerFlow(
170  client_id=client_id,
171  client_secret=client_secret,
172  scope=access.scope,
173  redirect_uri="",
174  )
175  try:
176  device_flow_info = await hass.async_add_executor_job(
177  oauth_flow.step1_get_device_and_user_codes
178  )
179  except OAuth2DeviceCodeError as err:
180  _LOGGER.debug("OAuth2DeviceCodeError error: %s", err)
181  # Web auth credentials reply with invalid_client when hitting this endpoint
182  if "Error: invalid_client" in str(err):
183  raise InvalidCredential(str(err)) from err
184  raise OAuthError(str(err)) from err
185  return DeviceFlow(hass, oauth_flow, device_flow_info)
186 
187 
188 class ApiAuthImpl(AbstractAuth):
189  """Authentication implementation for google calendar api library."""
190 
191  def __init__(
192  self,
193  websession: aiohttp.ClientSession,
194  session: config_entry_oauth2_flow.OAuth2Session,
195  ) -> None:
196  """Init the Google Calendar client library auth implementation."""
197  super().__init__(websession)
198  self._session_session = session
199 
200  async def async_get_access_token(self) -> str:
201  """Return a valid access token."""
202  await self._session_session.async_ensure_token_valid()
203  return cast(str, self._session_session.token["access_token"])
204 
205 
206 class AccessTokenAuthImpl(AbstractAuth):
207  """Authentication implementation used during config flow, without refresh.
208 
209  This exists to allow the config flow to use the API before it has fully
210  created a config entry required by OAuth2Session. This does not support
211  refreshing tokens, which is fine since it should have been just created.
212  """
213 
214  def __init__(
215  self,
216  websession: aiohttp.ClientSession,
217  access_token: str,
218  ) -> None:
219  """Init the Google Calendar client library auth implementation."""
220  super().__init__(websession)
221  self._access_token_access_token = access_token
222 
223  async def async_get_access_token(self) -> str:
224  """Return the access token."""
225  return self._access_token_access_token
None __init__(self, aiohttp.ClientSession websession, str access_token)
Definition: api.py:218
None __init__(self, aiohttp.ClientSession websession, config_entry_oauth2_flow.OAuth2Session session)
Definition: api.py:195
None __init__(self, HomeAssistant hass, OAuth2WebServerFlow oauth_flow, DeviceFlowInfo device_flow_info)
Definition: api.py:76
None _async_poll_attempt(self, datetime.datetime now)
Definition: api.py:131
None async_set_listener(self, CALLBACK_TYPE update_callback)
Definition: api.py:100
None _async_timeout(self, datetime.datetime now)
Definition: api.py:144
dict async_resolve_external_data(self, Any external_data)
Definition: api.py:49
FeatureAccess get_feature_access(ConfigEntry config_entry)
Definition: api.py:158
DeviceFlow async_create_device_flow(HomeAssistant hass, str client_id, str client_secret, FeatureAccess access)
Definition: api.py:167
CALLBACK_TYPE async_track_point_in_utc_time(HomeAssistant hass, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action, datetime point_in_time)
Definition: event.py:1542
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679