From 6a3d2e54a2df71ea9588f0379aeddadc3763f6d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 May 2022 20:23:16 -0700 Subject: [PATCH] Handle OAuth2 rejection (#72040) --- .../helpers/config_entry_oauth2_flow.py | 30 +++++++--- homeassistant/strings.json | 1 + script/scaffold/generate.py | 1 + .../helpers/test_config_entry_oauth2_flow.py | 55 +++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index d369b872eb99..365ced249298 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -271,9 +271,10 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): ) -> FlowResult: """Create an entry for auth.""" # Flow has been triggered by external data - if user_input: + if user_input is not None: self.external_data = user_input - return self.async_external_step_done(next_step_id="creation") + next_step = "authorize_rejected" if "error" in user_input else "creation" + return self.async_external_step_done(next_step_id=next_step) try: async with async_timeout.timeout(10): @@ -311,6 +312,13 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): {"auth_implementation": self.flow_impl.domain, "token": token} ) + async def async_step_authorize_rejected(self, data: None = None) -> FlowResult: + """Step to handle flow rejection.""" + return self.async_abort( + reason="user_rejected_authorize", + description_placeholders={"error": self.external_data["error"]}, + ) + async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow. @@ -400,10 +408,8 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Receive authorization code.""" - if "code" not in request.query or "state" not in request.query: - return web.Response( - text=f"Missing code or state parameter in {request.url}" - ) + if "state" not in request.query: + return web.Response(text="Missing state parameter") hass = request.app["hass"] @@ -412,9 +418,17 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): if state is None: return web.Response(text="Invalid state") + user_input: dict[str, Any] = {"state": state} + + if "code" in request.query: + user_input["code"] = request.query["code"] + elif "error" in request.query: + user_input["error"] = request.query["error"] + else: + return web.Response(text="Missing code or error parameter") + await hass.config_entries.flow.async_configure( - flow_id=state["flow_id"], - user_input={"state": state, "code": request.query["code"]}, + flow_id=state["flow_id"], user_input=user_input ) return web.Response( diff --git a/homeassistant/strings.json b/homeassistant/strings.json index e4d363c22be5..9ae30becaeeb 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -74,6 +74,7 @@ "oauth2_missing_credentials": "The integration requires application credentials.", "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth2_user_rejected_authorize": "Account linking rejected: {error}", "reauth_successful": "Re-authentication was successful", "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", "cloud_not_connected": "Not connected to Home Assistant Cloud." diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 7f4188684638..b7e4c58d1a13 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -188,6 +188,7 @@ def _custom_tasks(template, info: Info) -> None: "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 248f3b8dbb0d..e5d220c55dfc 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -223,6 +223,61 @@ async def test_abort_if_oauth_error( assert result["reason"] == "oauth_error" +async def test_abort_if_oauth_rejected( + hass, + flow_handler, + local_impl, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, +): + """Check bad oauth token.""" + flow_handler.async_register_implementation(hass, local_impl) + config_entry_oauth2_flow.async_register_implementation( + hass, TEST_DOMAIN, MockOAuth2Implementation() + ) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pick_implementation" + + # Pick implementation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"implementation": TEST_DOMAIN} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=read+write" + ) + + client = await hass_client_no_auth() + resp = await client.get( + f"/auth/external/callback?error=access_denied&state={state}" + ) + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "user_rejected_authorize" + assert result["description_placeholders"] == {"error": "access_denied"} + + async def test_step_discovery(hass, flow_handler, local_impl): """Check flow triggers from discovery.""" flow_handler.async_register_implementation(hass, local_impl)