Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Google integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Mapping
7 import logging
8 from typing import Any
9 
10 from gcal_sync.api import GoogleCalendarService
11 from gcal_sync.exceptions import ApiException, ApiForbiddenException
12 import voluptuous as vol
13 
14 from homeassistant.config_entries import (
15  SOURCE_REAUTH,
16  ConfigEntry,
17  ConfigFlowResult,
18  OptionsFlow,
19 )
20 from homeassistant.core import callback
21 from homeassistant.helpers import config_entry_oauth2_flow
22 from homeassistant.helpers.aiohttp_client import async_get_clientsession
23 
24 from .api import (
25  DEVICE_AUTH_CREDS,
26  AccessTokenAuthImpl,
27  DeviceFlow,
28  GoogleHybridAuth,
29  InvalidCredential,
30  OAuthError,
31  async_create_device_flow,
32 )
33 from .const import (
34  CONF_CALENDAR_ACCESS,
35  CONF_CREDENTIAL_TYPE,
36  DEFAULT_FEATURE_ACCESS,
37  DOMAIN,
38  CredentialType,
39  FeatureAccess,
40 )
41 
42 _LOGGER = logging.getLogger(__name__)
43 
44 
46  config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
47 ):
48  """Config flow to handle Google Calendars OAuth2 authentication.
49 
50  Historically, the Google Calendar integration instructed users to use
51  Device Auth. Device Auth was considered easier to use since it did not
52  require users to configure a redirect URL. Device Auth is meant for
53  devices with limited input, such as a television.
54  https://developers.google.com/identity/protocols/oauth2/limited-input-device
55 
56  Device Auth is limited to a small set of Google APIs (calendar is allowed)
57  and is considered less secure than Web Auth. It is not generally preferred
58  and may be limited/deprecated in the future similar to App/OOB Auth
59  https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html
60 
61  Web Auth is the preferred method by Home Assistant and Google, and a benefit
62  is that the same credentials may be used across many Google integrations in
63  Home Assistant. Web Auth is now easier for user to setup using my.home-assistant.io
64  redirect urls.
65 
66  The Application Credentials integration does not currently record which type
67  of credential the user entered (and if we ask the user, they may not know or may
68  make a mistake) so we try to determine the credential type automatically. This
69  implementation first attempts Device Auth by talking to the token API in the first
70  step of the device flow, then if that fails it will redirect using Web Auth.
71  There is not another explicit known way to check.
72  """
73 
74  DOMAIN = DOMAIN
75 
76  _exchange_finished_task: asyncio.Task[bool] | None = None
77 
78  def __init__(self) -> None:
79  """Set up instance."""
80  super().__init__()
81  self._device_flow_device_flow: DeviceFlow | None = None
82  # First attempt is device auth, then fallback to web auth
83  self._web_auth_web_auth = False
84 
85  @property
86  def logger(self) -> logging.Logger:
87  """Return logger."""
88  return logging.getLogger(__name__)
89 
90  @property
91  def extra_authorize_data(self) -> dict[str, Any]:
92  """Extra data that needs to be appended to the authorize url."""
93  return {
94  "scope": DEFAULT_FEATURE_ACCESS.scope,
95  # Add params to ensure we get back a refresh token
96  "access_type": "offline",
97  "prompt": "consent",
98  }
99 
100  async def async_step_auth(
101  self, user_input: dict[str, Any] | None = None
102  ) -> ConfigFlowResult:
103  """Create an entry for auth."""
104  # The default behavior from the parent class is to redirect the
105  # user with an external step. When using the device flow, we instead
106  # prompt the user to visit a URL and enter a code. The device flow
107  # background task will poll the exchange endpoint to get valid
108  # creds or until a timeout is complete.
109  if self._web_auth_web_auth:
110  return await super().async_step_auth(user_input)
111 
112  if self._exchange_finished_task_exchange_finished_task and self._exchange_finished_task_exchange_finished_task.done():
113  return self.async_show_progress_done(next_step_id="creation")
114 
115  if not self._device_flow_device_flow:
116  _LOGGER.debug("Creating GoogleHybridAuth flow")
117  if not isinstance(self.flow_impl, GoogleHybridAuth):
118  _LOGGER.error(
119  "Unexpected OAuth implementation does not support device auth: %s",
120  self.flow_impl,
121  )
122  return self.async_abort(reason="oauth_error")
123  calendar_access = DEFAULT_FEATURE_ACCESS
124  if self.sourcesource == SOURCE_REAUTH and (
125  reauth_options := self._get_reauth_entry().options
126  ):
127  calendar_access = FeatureAccess[reauth_options[CONF_CALENDAR_ACCESS]]
128  try:
129  device_flow = await async_create_device_flow(
130  self.hass,
131  self.flow_impl.client_id,
132  self.flow_impl.client_secret,
133  calendar_access,
134  )
135  except TimeoutError as err:
136  _LOGGER.error("Timeout initializing device flow: %s", str(err))
137  return self.async_abort(reason="timeout_connect")
138  except InvalidCredential:
139  _LOGGER.debug("Falling back to Web Auth and restarting flow")
140  self._web_auth_web_auth = True
141  return await super().async_step_auth()
142  except OAuthError as err:
143  _LOGGER.error("Error initializing device flow: %s", str(err))
144  return self.async_abort(reason="oauth_error")
145  self._device_flow_device_flow = device_flow
146 
147  exchange_finished_evt = asyncio.Event()
148  self._exchange_finished_task_exchange_finished_task = self.hass.async_create_task(
149  exchange_finished_evt.wait()
150  )
151 
152  def _exchange_finished() -> None:
153  self.external_dataexternal_data = {
154  DEVICE_AUTH_CREDS: device_flow.creds
155  } # is None on timeout/expiration
156  exchange_finished_evt.set()
157 
158  device_flow.async_set_listener(_exchange_finished)
159  device_flow.async_start_exchange()
160 
161  return self.async_show_progress(
162  step_id="auth",
163  description_placeholders={
164  "url": self._device_flow_device_flow.verification_url,
165  "user_code": self._device_flow_device_flow.user_code,
166  },
167  progress_action="exchange",
168  progress_task=self._exchange_finished_task_exchange_finished_task,
169  )
170 
172  self, user_input: dict[str, Any] | None = None
173  ) -> ConfigFlowResult:
174  """Handle external yaml configuration."""
175  if not self._web_auth_web_auth and self.external_dataexternal_data.get(DEVICE_AUTH_CREDS) is None:
176  return self.async_abort(reason="code_expired")
177  return await super().async_step_creation(user_input)
178 
179  async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
180  """Create an entry for the flow, or update existing entry."""
181  data[CONF_CREDENTIAL_TYPE] = (
182  CredentialType.WEB_AUTH if self._web_auth_web_auth else CredentialType.DEVICE_AUTH
183  )
184  if self.sourcesource == SOURCE_REAUTH:
185  return self.async_update_reload_and_abort(
186  self._get_reauth_entry(), data=data
187  )
188  calendar_service = GoogleCalendarService(
190  async_get_clientsession(self.hass), data["token"]["access_token"]
191  )
192  )
193  try:
194  primary_calendar = await calendar_service.async_get_calendar("primary")
195  except ApiForbiddenException as err:
196  _LOGGER.error(
197  "Error reading primary calendar, make sure Google Calendar API is enabled: %s",
198  err,
199  )
200  return self.async_abort(reason="api_disabled")
201  except ApiException as err:
202  _LOGGER.error("Error reading primary calendar: %s", err)
203  return self.async_abort(reason="cannot_connect")
204  await self.async_set_unique_id(primary_calendar.id)
205 
206  if found := self.hass.config_entries.async_entry_for_domain_unique_id(
207  self.handler, primary_calendar.id
208  ):
209  _LOGGER.debug("Found existing '%s' entry: %s", primary_calendar.id, found)
210 
211  self._abort_if_unique_id_configured()
212  return self.async_create_entry(
213  title=primary_calendar.id,
214  data=data,
215  options={
216  CONF_CALENDAR_ACCESS: DEFAULT_FEATURE_ACCESS.name,
217  },
218  )
219 
220  async def async_step_reauth(
221  self, entry_data: Mapping[str, Any]
222  ) -> ConfigFlowResult:
223  """Perform reauth upon an API authentication error."""
224  self._web_auth_web_auth = entry_data.get(CONF_CREDENTIAL_TYPE) == CredentialType.WEB_AUTH
225  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
226 
228  self, user_input: dict[str, Any] | None = None
229  ) -> ConfigFlowResult:
230  """Confirm reauth dialog."""
231  if user_input is None:
232  return self.async_show_form(step_id="reauth_confirm")
233  return await self.async_step_user()
234 
235  @staticmethod
236  @callback
238  config_entry: ConfigEntry,
239  ) -> OptionsFlow:
240  """Create an options flow."""
241  return OptionsFlowHandler()
242 
243 
245  """Google Calendar options flow."""
246 
247  async def async_step_init(
248  self, user_input: dict[str, Any] | None = None
249  ) -> ConfigFlowResult:
250  """Manage the options."""
251  if user_input is not None:
252  return self.async_create_entryasync_create_entry(title="", data=user_input)
253 
254  return self.async_show_formasync_show_form(
255  step_id="init",
256  data_schema=vol.Schema(
257  {
258  vol.Required(
259  CONF_CALENDAR_ACCESS,
260  default=self.config_entryconfig_entryconfig_entry.options.get(CONF_CALENDAR_ACCESS),
261  ): vol.In(
262  {
263  "read_write": "Read/Write access (can create events)",
264  "read_only": "Read-only access",
265  }
266  )
267  }
268  ),
269  )
ConfigFlowResult async_step_creation(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:173
OptionsFlow async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:239
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:229
ConfigFlowResult async_step_auth(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:102
ConfigFlowResult async_oauth_create_entry(self, dict data)
Definition: config_flow.py:179
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:222
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:249
None config_entry(self, ConfigEntry value)
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
DeviceFlow async_create_device_flow(HomeAssistant hass, str client_id, str client_secret, FeatureAccess access)
Definition: api.py:167
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)