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
This commit is contained in:
Allen Porter 2021-11-04 15:56:16 -07:00 committed by GitHub
parent 54e7ef08e3
commit fa4e890696
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 269 additions and 44 deletions

View file

@ -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,
),
)

View file

@ -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:

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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