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