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:
parent
54e7ef08e3
commit
fa4e890696
6 changed files with 269 additions and 44 deletions
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue