From fa4e8906961948936fbf10e94864bb9637d4b9ea Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 4 Nov 2021 15:56:16 -0700 Subject: [PATCH] Revamp nest authentication config flows and remove need for redirect urls (#59033) * Add support for Installed Auth authentication flows. Add support for additional credential types to make configuration simpler for end users. The existing Web App auth flow requires users to configure redirect urls with Google that has a very high security bar: requires ssl, and a publicly resolvable dns name. The new Installed App flow requires the user to copy/paste an access code and is the same flow used by the `google` calendar integration. This also allows us to let users create one authentication credential to use with multiple google integrations. * Remove hard migration for nest config entries, using soft migration * Add comment explaining soft migration * Revet changes to common.py made obsolete by removing migration * Reduce unnecessary diffs in nest common.py * Update config entries using library method * Run `python3 -m script.translations develop` * Revert nest auth domain * Remove compat function which is no longer needed * Remove stale nest comment * Adjust typing for python3.8 * Address PR feedback for nest auth revamp --- homeassistant/components/nest/__init__.py | 69 ++++++- homeassistant/components/nest/config_flow.py | 50 ++++- homeassistant/components/nest/const.py | 1 + homeassistant/components/nest/strings.json | 7 + .../components/nest/translations/en.json | 7 + tests/components/nest/test_config_flow_sdm.py | 179 +++++++++++++++--- 6 files changed, 269 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index fce475e1788..1cbe97dc3b7 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -30,7 +30,14 @@ from homeassistant.helpers import ( from homeassistant.helpers.typing import ConfigType from . import api, config_flow -from .const import DATA_SDM, DATA_SUBSCRIBER, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from .const import ( + DATA_SDM, + DATA_SUBSCRIBER, + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + OOB_REDIRECT_URI, +) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry @@ -68,6 +75,51 @@ CONFIG_SCHEMA = vol.Schema( # Platforms for SDM API PLATFORMS = ["sensor", "camera", "climate"] +WEB_AUTH_DOMAIN = DOMAIN +INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed" + + +class WebAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): + """OAuth implementation using OAuth for web applications.""" + + name = "OAuth for Web" + + def __init__( + self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str + ) -> None: + """Initialize WebAuth.""" + super().__init__( + hass, + WEB_AUTH_DOMAIN, + client_id, + client_secret, + OAUTH2_AUTHORIZE.format(project_id=project_id), + OAUTH2_TOKEN, + ) + + +class InstalledAppAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): + """OAuth implementation using OAuth for installed applications.""" + + name = "OAuth for Apps" + + def __init__( + self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str + ) -> None: + """Initialize InstalledAppAuth.""" + super().__init__( + hass, + INSTALLED_AUTH_DOMAIN, + client_id, + client_secret, + OAUTH2_AUTHORIZE.format(project_id=project_id), + OAUTH2_TOKEN, + ) + + @property + def redirect_uri(self) -> str: + """Return the redirect uri.""" + return OOB_REDIRECT_URI async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -90,13 +142,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: config_flow.NestFlowHandler.register_sdm_api(hass) config_flow.NestFlowHandler.async_register_implementation( hass, - config_entry_oauth2_flow.LocalOAuth2Implementation( + InstalledAppAuth( hass, - DOMAIN, config[DOMAIN][CONF_CLIENT_ID], config[DOMAIN][CONF_CLIENT_SECRET], - OAUTH2_AUTHORIZE.format(project_id=project_id), - OAUTH2_TOKEN, + project_id, + ), + ) + config_flow.NestFlowHandler.async_register_implementation( + hass, + WebAuth( + hass, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + project_id, ), ) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 189a8189e8a..ec567aaa14e 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -1,15 +1,12 @@ """Config flow to configure Nest. -This configuration flow supports two APIs: - - The new Device Access program and the Smart Device Management API - - The legacy nest API +This configuration flow supports the following: + - SDM API with Installed app flow where user enters an auth code manually + - SDM API with Web OAuth flow with redirect back to Home Assistant + - Legacy Nest API auth flow with where user enters an auth code manually NestFlowHandler is an implementation of AbstractOAuth2FlowHandler with -some overrides to support the old APIs auth flow. That is, for the new -API this class has hardly any special config other than url parameters, -and everything else custom is for the old api. When configured with the -new api via NestFlowHandler.register_sdm_api, the custom methods just -invoke the AbstractOAuth2FlowHandler methods. +some overrides to support installed app and old APIs auth flow. """ from __future__ import annotations @@ -28,7 +25,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.json import load_json -from .const import DATA_SDM, DOMAIN, SDM_SCOPES +from .const import DATA_SDM, DOMAIN, OOB_REDIRECT_URI, SDM_SCOPES DATA_FLOW_IMPL = "nest_flow_implementation" _LOGGER = logging.getLogger(__name__) @@ -154,6 +151,14 @@ class NestFlowHandler( step_id="reauth_confirm", data_schema=vol.Schema({}), ) + existing_entries = self._async_current_entries() + if existing_entries: + # Pick an existing auth implementation for Reauth if present. Note + # only one ConfigEntry is allowed so its safe to pick the first. + entry = next(iter(existing_entries)) + if "auth_implementation" in entry.data: + data = {"implementation": entry.data["auth_implementation"]} + return await self.async_step_user(data) return await self.async_step_user() async def async_step_user( @@ -167,6 +172,33 @@ class NestFlowHandler( return await super().async_step_user(user_input) return await self.async_step_init(user_input) + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create an entry for auth.""" + if self.flow_impl.domain == "nest.installed": + # The default behavior from the parent class is to redirect the + # user with an external step. When using installed app auth, we + # instead prompt the user to sign in and copy/paste and + # authentication code back into this form. + # Note: This is similar to the Legacy API flow below, but it is + # simpler to reuse the OAuth logic in the parent class than to + # reuse SDM code with Legacy API code. + if user_input is not None: + self.external_data = { + "code": user_input["code"], + "state": {"redirect_uri": OOB_REDIRECT_URI}, + } + return await super().async_step_creation(user_input) + + result = await super().async_step_auth() + return self.async_show_form( + step_id="auth", + description_placeholders={"url": result["url"]}, + data_schema=vol.Schema({vol.Required("code"): str}), + ) + return await super().async_step_auth(user_input) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index 3aba9ef5a7e..25b43de1032 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -16,3 +16,4 @@ SDM_SCOPES = [ "https://www.googleapis.com/auth/pubsub", ] API_URL = "https://smartdevicemanagement.googleapis.com/v1" +OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 26ec49c0d75..84cfc3435a6 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -4,6 +4,13 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, + "auth": { + "title": "Link Google Account", + "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", + "data": { + "code": "[%key:common::config_flow::data::access_token%]" + } + }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Nest integration needs to re-authenticate your account" diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index 4487beb0f43..be35cf1b54e 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -18,6 +18,13 @@ "unknown": "Unexpected error" }, "step": { + "auth": { + "data": { + "code": "Access Token" + }, + "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", + "title": "Link Google Account" + }, "init": { "data": { "flow_impl": "Provider" diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index a8f892045f5..2b7ac71d44c 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -6,6 +6,7 @@ import pytest from homeassistant import config_entries, setup from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers import config_entry_oauth2_flow @@ -26,6 +27,12 @@ CONFIG = { "http": {"base_url": "https://example.com"}, } +ORIG_AUTH_DOMAIN = DOMAIN +WEB_AUTH_DOMAIN = DOMAIN +APP_AUTH_DOMAIN = f"{DOMAIN}.installed" +WEB_REDIRECT_URL = "https://example.com/auth/external/callback" +APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" + def get_config_entry(hass): """Return a single config entry.""" @@ -43,31 +50,65 @@ class OAuthFixture: self.hass_client = hass_client_no_auth self.aioclient_mock = aioclient_mock - async def async_oauth_flow(self, result): - """Invoke the oauth flow with fake responses.""" - state = config_entry_oauth2_flow._encode_jwt( - self.hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, + async def async_pick_flow(self, result: dict, auth_domain: str) -> dict: + """Invoke flow to puth the auth type to use for this flow.""" + assert result["type"] == "form" + assert result["step_id"] == "pick_implementation" + + return await self.hass.config_entries.flow.async_configure( + result["flow_id"], {"implementation": auth_domain} ) - oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID) - assert result["type"] == "external" - assert result["url"] == ( - f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service" - "+https://www.googleapis.com/auth/pubsub" - "&access_type=offline&prompt=consent" - ) + async def async_oauth_web_flow(self, result: dict) -> ConfigEntry: + """Invoke the oauth flow for Web Auth with fake responses.""" + state = self.create_state(result, WEB_REDIRECT_URL) + assert result["url"] == self.authorize_url(state, WEB_REDIRECT_URL) + # Simulate user redirect back with auth code client = await self.hass_client() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" + return await self.async_finish_flow(result) + + async def async_oauth_app_flow(self, result: dict) -> ConfigEntry: + """Invoke the oauth flow for Installed Auth with fake responses.""" + # Render form with a link to get an auth token + assert result["type"] == "form" + assert result["step_id"] == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + state = self.create_state(result, APP_REDIRECT_URL) + assert result["description_placeholders"]["url"] == self.authorize_url( + state, APP_REDIRECT_URL + ) + # Simulate user entering auth token in form + return await self.async_finish_flow(result, {"code": "abcd"}) + + def create_state(self, result: dict, redirect_url: str) -> str: + """Create state object based on redirect url.""" + return config_entry_oauth2_flow._encode_jwt( + self.hass, + { + "flow_id": result["flow_id"], + "redirect_uri": redirect_url, + }, + ) + + def authorize_url(self, state: str, redirect_url: str) -> str: + """Generate the expected authorization url.""" + oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID) + return ( + f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={redirect_url}" + f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service" + "+https://www.googleapis.com/auth/pubsub" + "&access_type=offline&prompt=consent" + ) + + async def async_finish_flow(self, result, user_input: dict = None) -> ConfigEntry: + """Finish the OAuth flow exchanging auth token for refresh token.""" self.aioclient_mock.post( OAUTH2_TOKEN, json={ @@ -81,8 +122,13 @@ class OAuthFixture: with patch( "homeassistant.components.nest.async_setup_entry", return_value=True ) as mock_setup: - await self.hass.config_entries.flow.async_configure(result["flow_id"]) + await self.hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) assert len(mock_setup.mock_calls) == 1 + await self.hass.async_block_till_done() + + return get_config_entry(self.hass) @pytest.fixture @@ -91,17 +137,18 @@ async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_ return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) -async def test_full_flow(hass, oauth): +async def test_web_full_flow(hass, oauth): """Check full flow.""" assert await setup.async_setup_component(hass, DOMAIN, CONFIG) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - await oauth.async_oauth_flow(result) - entry = get_config_entry(hass) - assert entry.title == "Configuration.yaml" + result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) + + entry = await oauth.async_oauth_web_flow(result) + assert entry.title == "OAuth for Web" assert "token" in entry.data entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN @@ -113,7 +160,7 @@ async def test_full_flow(hass, oauth): } -async def test_reauth(hass, oauth): +async def test_web_reauth(hass, oauth): """Test Nest reauthentication.""" assert await setup.async_setup_component(hass, DOMAIN, CONFIG) @@ -121,7 +168,7 @@ async def test_reauth(hass, oauth): old_entry = MockConfigEntry( domain=DOMAIN, data={ - "auth_implementation": DOMAIN, + "auth_implementation": WEB_AUTH_DOMAIN, "token": { # Verify this is replaced at end of the test "access_token": "some-revoked-token", @@ -148,10 +195,9 @@ async def test_reauth(hass, oauth): # Run the oauth flow result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) - await oauth.async_oauth_flow(result) + entry = await oauth.async_oauth_web_flow(result) # Verify existing tokens are replaced - entry = get_config_entry(hass) entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN assert entry.data["token"] == { @@ -160,12 +206,13 @@ async def test_reauth(hass, oauth): "type": "Bearer", "expires_in": 60, } + assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN async def test_single_config_entry(hass): """Test that only a single config entry is allowed.""" old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}} + domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} ) old_entry.add_to_hass(hass) @@ -187,12 +234,12 @@ async def test_unexpected_existing_config_entries(hass, oauth): assert await setup.async_setup_component(hass, DOMAIN, CONFIG) old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}} + domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} ) old_entry.add_to_hass(hass) old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}} + domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} ) old_entry.add_to_hass(hass) @@ -209,7 +256,7 @@ async def test_unexpected_existing_config_entries(hass, oauth): flows = hass.config_entries.flow.async_progress() result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) - await oauth.async_oauth_flow(result) + await oauth.async_oauth_web_flow(result) # Only a single entry now exists, and the other was cleaned up entries = hass.config_entries.async_entries(DOMAIN) @@ -223,3 +270,75 @@ async def test_unexpected_existing_config_entries(hass, oauth): "type": "Bearer", "expires_in": 60, } + + +async def test_app_full_flow(hass, oauth, aioclient_mock): + """Check full flow.""" + assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + + entry = await oauth.async_oauth_app_flow(result) + assert entry.title == "OAuth for Apps" + assert "token" in entry.data + entry.data["token"].pop("expires_at") + assert entry.unique_id == DOMAIN + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + + +async def test_app_reauth(hass, oauth): + """Test Nest reauthentication for Installed App Auth.""" + + assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + + old_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": APP_AUTH_DOMAIN, + "token": { + # Verify this is replaced at end of the test + "access_token": "some-revoked-token", + }, + "sdm": {}, + }, + unique_id=DOMAIN, + ) + old_entry.add_to_hass(hass) + + entry = get_config_entry(hass) + assert entry.data["token"] == { + "access_token": "some-revoked-token", + } + + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_entry.data + ) + + # Advance through the reauth flow + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # Run the oauth flow + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + await oauth.async_oauth_app_flow(result) + + # Verify existing tokens are replaced + entry = get_config_entry(hass) + entry.data["token"].pop("expires_at") + assert entry.unique_id == DOMAIN + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN