From e055c339746ff256077263ac230654d102f9cea6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 11 Feb 2022 08:28:38 -0800 Subject: [PATCH 01/37] Initial developer credentials scaffolding - Support websocket list/add/delete - Add developer credentials protocol from yaml config - Handle OAuth credential registration and de-registration - Tests for websocket and integration based registration --- CODEOWNERS | 2 + .../developer_credentials/__init__.py | 270 +++++++++++ .../components/developer_credentials/const.py | 12 + .../developer_credentials/manifest.json | 9 + .../developer_credentials/strings.json | 3 + .../translations/en.json | 21 + .../helpers/config_entry_oauth2_flow.py | 7 +- script/hassfest/manifest.py | 1 + .../developer_credentials/__init__.py | 1 + .../developer_credentials/test_init.py | 429 ++++++++++++++++++ 10 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/developer_credentials/__init__.py create mode 100644 homeassistant/components/developer_credentials/const.py create mode 100644 homeassistant/components/developer_credentials/manifest.json create mode 100644 homeassistant/components/developer_credentials/strings.json create mode 100644 homeassistant/components/developer_credentials/translations/en.json create mode 100644 tests/components/developer_credentials/__init__.py create mode 100644 tests/components/developer_credentials/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index c3405001f236a..7b87fb1b0bd5a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -216,6 +216,8 @@ build.json @home-assistant/supervisor /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney /tests/components/derivative/ @afaucogney +/homeassistant/components/developer_credentials/ @allenporter +/tests/components/developer_credentials/ @allenporter /homeassistant/components/device_automation/ @home-assistant/core /tests/components/device_automation/ @home-assistant/core /homeassistant/components/device_tracker/ @home-assistant/core diff --git a/homeassistant/components/developer_credentials/__init__.py b/homeassistant/components/developer_credentials/__init__.py new file mode 100644 index 0000000000000..e7174908aaf9b --- /dev/null +++ b/homeassistant/components/developer_credentials/__init__.py @@ -0,0 +1,270 @@ +"""The Developer Credentials integration. + +This integration provides APIs for managing local OAuth credentials on behalf of other +integrations. The preferred approach for all OAuth integrations is to use the cloud +account linking service, and this is the alternative for integrations that can't use +it. + +Integrations register an authorization server, and then the APIs are used to add +add one or more developer credentials. Integrations may also provide credentials +from yaml for backwards compatibility. +""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Protocol + +import voluptuous as vol + +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + collection, + config_entry_oauth2_flow, + integration_platform, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import slugify + +from .const import DOMAIN, DeveloperCredentialsType + +__all__ = ["DeveloperCredential", "AuthorizationServer"] + +_LOGGER = logging.getLogger(__name__) + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CREATE_FIELDS = { + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, +} +UPDATE_FIELDS: dict = {} # Not supported + + +@dataclass +class DeveloperCredential: + """Represent a developer credential.""" + + client_id: str + client_secret: str + + +@dataclass +class AuthorizationServer: + """Represent an OAuth2 Authorization Server.""" + + authorize_url: str + token_url: str + + +class DeveloperCredentialsStorageCollection(collection.StorageCollection): + """Input boolean collection stored in storage.""" + + CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + result = self.CREATE_SCHEMA(data) + domain = result[CONF_DOMAIN] + authorization_server = await _async_get_authorization_server(self.hass, domain) + if not authorization_server: + raise ValueError("No authorization server registered for %s" % domain) + return result + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}" + + async def _update_data(self, data: dict, update_data: dict) -> dict: + """Return a new updated data object.""" + raise ValueError("Updates not supported") + + async def async_delete_item(self, item_id: str) -> None: + """Delete item, verifying credential is not in use.""" + if item_id not in self.data: + raise collection.ItemNotFound(item_id) + + # Cannot delete a credential currently in use by a ConfigEntry + current = self.data[item_id] + entries = self.hass.config_entries.async_entries(current[CONF_DOMAIN]) + for entry in entries: + if entry.data.get("auth_implementation") == item_id: + raise ValueError("Cannot delete credential in use by an integration") + + await super().async_delete_item(item_id) + + +class DeveloperCredentialsStorageListener: + """Listener that handles registering and unregistering OAuth implementations for credentials.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize DeveloperCredentialsStorageListener.""" + self.hass = hass + + async def updated(self, change_type: str, item_id: str, config: dict): + """Update set of registered authentication implementations.""" + if change_type not in [collection.CHANGE_ADDED, collection.CHANGE_REMOVED]: + # not expected + return + integration_domain = config[CONF_DOMAIN] + developer_credential = DeveloperCredential( + config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET] + ) + if change_type == collection.CHANGE_REMOVED: + _async_unregister_auth_implementation( + self.hass, integration_domain, developer_credential + ) + else: + await _async_register_auth_implementation( + self.hass, integration_domain, developer_credential + ) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Developer Credentials.""" + hass.data[DOMAIN] = {} + + await integration_platform.async_process_integration_platforms( + hass, DOMAIN, _register_developer_credentials_platform + ) + + # Developer Credentials from storage + id_manager = collection.IDManager() + storage_collection = DeveloperCredentialsStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY), + logging.getLogger(f"{__name__}.storage_collection"), + id_manager, + ) + storage_listener = DeveloperCredentialsStorageListener(hass) + storage_collection.async_add_listener(storage_listener.updated) + await storage_collection.async_load() + + collection.StorageCollectionWebsocket( + storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS + ).async_setup(hass) + + # Allow future registration of local oauth implementations + config_entry_oauth2_flow.async_register_local_apis(hass) + + return True + + +# Creates AbstractOAuth2Implementation given a DeveloperCredential for a specific domain +AuthImplFactory = Callable[ + [str, DeveloperCredential], config_entry_oauth2_flow.AbstractOAuth2Implementation +] + + +def _get_auth_domain(domain: str, developer_credential: DeveloperCredential) -> str: + """Return the OAuth2 flow implementation domain.""" + return slugify(f"{domain}.{developer_credential.client_id}") + + +async def _async_register_auth_implementation( + hass: HomeAssistant, + domain: str, + developer_credential: DeveloperCredential, +) -> None: + """Register an OAuth2 flow implementation for an integration.""" + auth_domain = _get_auth_domain(domain, developer_credential) + authorization_server = await _async_get_authorization_server(hass, domain) + if not authorization_server: + raise ValueError("No authorization server registered for %s" % domain) + auth_impl = config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + auth_domain, + developer_credential.client_id, + developer_credential.client_secret, + authorization_server.authorize_url, + authorization_server.token_url, + ) + unsub = config_entry_oauth2_flow.async_register_implementation( + hass, domain, auth_impl + ) + hass.data[DOMAIN][domain][auth_domain] = unsub + + +def _async_unregister_auth_implementation( + hass: HomeAssistant, domain: str, developer_credential: DeveloperCredential +) -> None: + """Register an OAuth2 flow implementation for an integration.""" + auth_domain = _get_auth_domain(domain, developer_credential) + unsub = hass.data[DOMAIN][domain][auth_domain] + unsub() + + +async def _async_get_authorization_server( + hass: HomeAssistant, domain: str +) -> AuthorizationServer | None: + """Return the AuthorizationServer for the integration domain.""" + if domain not in hass.data[DOMAIN]: + return None + get_authorization_server = hass.data[DOMAIN][domain][ + DeveloperCredentialsType.AUTHORIZATION_SERVER.value + ] + authorization_server = await get_authorization_server(hass) + if not authorization_server: + return None + return authorization_server + + +async def _async_get_developer_credential( + hass: HomeAssistant, domain: str +) -> DeveloperCredential | None: + """Return the DeveloperCredential for the integration domain.""" + if domain not in hass.data[DOMAIN]: + return None + get_developer_credential = hass.data[DOMAIN][domain][ + DeveloperCredentialsType.CONFIG_CREDENTIAL.value + ] + return await get_developer_credential(hass) + + +class DeveloperCredentialsProtocol(Protocol): + """Define the format that developer_credentials platforms can have.""" + + async def async_get_authorization_server( + self, hass: HomeAssistant + ) -> AuthorizationServer: + """Return authorization server.""" + + async def async_get_developer_credential( + self, hass: HomeAssistant + ) -> DeveloperCredential: + """Return a developer credential from configuration.yaml.""" + + +async def _register_developer_credentials_platform( + hass: HomeAssistant, + integration_domain: str, + platform: DeveloperCredentialsProtocol, +): + """Register a developer_credentials platform.""" + get_authorization_server = getattr(platform, "async_get_authorization_server", None) + if get_authorization_server is None: + return + get_config_developer_credential = getattr( + platform, "async_get_developer_credential", None + ) + hass.data[DOMAIN][integration_domain] = { + DeveloperCredentialsType.AUTHORIZATION_SERVER.value: get_authorization_server, + DeveloperCredentialsType.CONFIG_CREDENTIAL.value: get_config_developer_credential, + } + # Register an authentication implementation for every credential provided + if get_config_developer_credential is None: + return + developer_credential = await _async_get_developer_credential( + hass, integration_domain + ) + if not developer_credential: + return + await _async_register_auth_implementation( + hass, integration_domain, developer_credential + ) diff --git a/homeassistant/components/developer_credentials/const.py b/homeassistant/components/developer_credentials/const.py new file mode 100644 index 0000000000000..93c6fac75a43f --- /dev/null +++ b/homeassistant/components/developer_credentials/const.py @@ -0,0 +1,12 @@ +"""Constants for the Developer Credentials integration.""" + +from homeassistant.backports.enum import StrEnum + +DOMAIN = "developer_credentials" + + +class DeveloperCredentialsType(StrEnum): + """Developer Credentials types.""" + + AUTHORIZATION_SERVER = "authorization_server" + CONFIG_CREDENTIAL = "config_credential" diff --git a/homeassistant/components/developer_credentials/manifest.json b/homeassistant/components/developer_credentials/manifest.json new file mode 100644 index 0000000000000..f602775e5db84 --- /dev/null +++ b/homeassistant/components/developer_credentials/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "developer_credentials", + "name": "Developer Credentials", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/developer_credentials", + "dependencies": ["http"], + "codeowners": ["@allenporter"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/developer_credentials/strings.json b/homeassistant/components/developer_credentials/strings.json new file mode 100644 index 0000000000000..86cb8a690d971 --- /dev/null +++ b/homeassistant/components/developer_credentials/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Developer Credentials" +} diff --git a/homeassistant/components/developer_credentials/translations/en.json b/homeassistant/components/developer_credentials/translations/en.json new file mode 100644 index 0000000000000..f15fe84c3ed6e --- /dev/null +++ b/homeassistant/components/developer_credentials/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index e2b21522d42e1..dd5156e39ea9b 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -328,11 +328,16 @@ def async_register_implementation( @callback def async_register_implementation( hass: HomeAssistant, domain: str, implementation: AbstractOAuth2Implementation -) -> None: +) -> Callable[[], None]: """Register an OAuth2 flow implementation for an integration.""" implementations = hass.data.setdefault(DATA_IMPLEMENTATIONS, {}) implementations.setdefault(domain, {})[implementation.domain] = implementation + def unsub() -> None: + del implementations[domain][implementation.domain] + + return unsub + async def async_get_implementations( hass: HomeAssistant, domain: str diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index ca9acedd515fa..5cf2fd01a2ada 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -44,6 +44,7 @@ "configurator", "counter", "default_config", + "developer_credentials", "device_automation", "device_tracker", "diagnostics", diff --git a/tests/components/developer_credentials/__init__.py b/tests/components/developer_credentials/__init__.py new file mode 100644 index 0000000000000..09e4e6cbe8a8b --- /dev/null +++ b/tests/components/developer_credentials/__init__.py @@ -0,0 +1 @@ +"""Tests for the Developer Credentials integration.""" diff --git a/tests/components/developer_credentials/test_init.py b/tests/components/developer_credentials/test_init.py new file mode 100644 index 0000000000000..c822100279dd0 --- /dev/null +++ b/tests/components/developer_credentials/test_init.py @@ -0,0 +1,429 @@ +"""Test the Developer Credentials integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Generator +import logging +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from aiohttp import ClientWebSocketResponse +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.developer_credentials import ( + AuthorizationServer, + DeveloperCredential, +) +from homeassistant.components.developer_credentials.const import DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from tests.common import mock_platform + +CLIENT_ID = "some-client-id" +CLIENT_SECRET = "some-client-secret" +DEVELOPER_CREDENTIAL = DeveloperCredential(CLIENT_ID, CLIENT_SECRET) +ID = "fake_integration_some_client_id" +AUTHORIZE_URL = "https://example.com/auth" +TOKEN_URL = "https://example.com/oauth2/v4/token" +REFRESH_TOKEN = "mock-refresh-token" +ACCESS_TOKEN = "mock-access-token" + +TEST_DOMAIN = "fake_integration" + + +@pytest.fixture +async def authorization_server() -> AuthorizationServer: + """Fixture AuthorizationServer for mock developer_credentials integration.""" + return AuthorizationServer(AUTHORIZE_URL, TOKEN_URL) + + +@pytest.fixture +async def config_credential() -> DeveloperCredential | None: + """Fixture DeveloperCredential for mock developer_credentials integration.""" + return None + + +@pytest.fixture(autouse=True) +async def mock_developer_credentials_integration( + hass, authorization_server, config_credential: DeveloperCredential | None +): + """Mock a developer_credentials integration.""" + hass.config.components.add(TEST_DOMAIN) + mock_platform( + hass, + f"{TEST_DOMAIN}.developer_credentials", + Mock( + async_get_authorization_server=AsyncMock(return_value=authorization_server), + async_get_developer_credential=AsyncMock(return_value=config_credential), + ), + ) + assert await async_setup_component(hass, "developer_credentials", {}) + + +class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow used during tests.""" + + DOMAIN = TEST_DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + +@pytest.fixture(autouse=True) +def config_flow_handler( + hass: HomeAssistant, current_request_with_host: Any +) -> Generator[FakeConfigFlow, None, None]: + """Fixture for a test config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + with patch.dict(config_entries.HANDLERS, {TEST_DOMAIN: FakeConfigFlow}): + yield FakeConfigFlow + + +class OAuthFixture: + """Fixture to facilitate testing an OAuth flow.""" + + def __init__(self, hass, hass_client, aioclient_mock): + """Initialize OAuthFixture.""" + self.hass = hass + self.hass_client = hass_client + self.aioclient_mock = aioclient_mock + self.client_id = CLIENT_ID + + async def complete_external_step( + self, result: data_entry_flow.FlowResult + ) -> data_entry_flow.FlowResult: + """Fixture method to complete the OAuth flow and return the completed result.""" + client = await self.hass_client() + state = config_entry_oauth2_flow._encode_jwt( + self.hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={self.client_id}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + 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" + + self.aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN, + "type": "bearer", + "expires_in": 60, + }, + ) + + result = await self.hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "Configuration.yaml" + assert "data" in result + assert "token" in result["data"] + return result + + +@pytest.fixture +async def oauth_fixture( + hass: HomeAssistant, hass_client_no_auth: Any, aioclient_mock: Any +) -> OAuthFixture: + """Fixture for testing the OAuth flow.""" + return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) + + +class Client: + """Test client with helper methods for developer credentials websocket.""" + + def __init__(self, client): + """Initialize Client.""" + self.client = client + self.id = 0 + + async def cmd(self, cmd: str, payload: dict[str, Any] = None) -> dict[str, Any]: + """Send a command and receive the json result.""" + self.id += 1 + await self.client.send_json( + { + "id": self.id, + "type": f"{DOMAIN}/{cmd}", + **(payload if payload is not None else {}), + } + ) + resp = await self.client.receive_json() + assert resp.get("id") == self.id + return resp + + async def cmd_result(self, cmd: str, payload: dict[str, Any] = None) -> Any: + """Send a command and parse the result.""" + resp = await self.cmd(cmd, payload) + assert resp.get("success") + assert resp.get("type") == "result" + return resp.get("result") + + +ClientFixture = Callable[[], Client] + + +@pytest.fixture +async def ws_client( + hass_ws_client: Callable[[...], ClientWebSocketResponse] +) -> ClientFixture: + """Fixture for creating the test websocket client.""" + + async def create_client() -> Client: + ws_client = await hass_ws_client() + return Client(ws_client) + + return create_client + + +async def test_websocket_list_empty(ws_client: ClientFixture): + """Test websocket list command.""" + client = await ws_client() + assert await client.cmd_result("list") == [] + + +async def test_websocket_create(ws_client: ClientFixture): + """Test websocket create command.""" + client = await ws_client() + result = await client.cmd_result( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + assert result == { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "id": ID, + } + + result = await client.cmd_result("list") + assert result == [ + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "id": ID, + } + ] + + +async def test_websocket_create_invalid_domain(ws_client: ClientFixture): + """Test websocket create command.""" + client = await ws_client() + resp = await client.cmd( + "create", + { + CONF_DOMAIN: "other-domain", + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "invalid_format" + assert ( + resp["error"].get("message") + == "No authorization server registered for other-domain" + ) + + +async def test_websocket_update_not_supported(ws_client: ClientFixture): + """Test websocket update command in unsupported.""" + client = await ws_client() + result = await client.cmd_result( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + assert result == { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "id": ID, + } + + resp = await client.cmd("update", {"developer_credentials_id": ID}) + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "invalid_format" + assert resp["error"].get("message") == "Updates not supported" + + +async def test_websocket_delete(ws_client: ClientFixture): + """Test websocket delete command.""" + client = await ws_client() + + await client.cmd_result( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + assert await client.cmd_result("list") == [ + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "id": ID, + } + ] + + await client.cmd_result("delete", {"developer_credentials_id": ID}) + assert await client.cmd_result("list") == [] + + +@pytest.mark.parametrize("config_credential", [DEVELOPER_CREDENTIAL]) +async def test_websocket_yaml_config(ws_client: ClientFixture): + """Test websocket list command for a yaml based credential.""" + client = await ws_client() + + # Configuration based creds are not returned from the websocket + assert await client.cmd_result("list") == [] + + # Configuration based cannot be deleted + resp = await client.cmd("delete", {"developer_credentials_id": ID}) + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "not_found" + + +async def test_config_flow_no_credentials(hass): + """Test config flow base case with no credentials registered.""" + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "missing_configuration" + + +async def test_config_flow( + hass: HomeAssistant, + ws_client: ClientFixture, + oauth_fixture: OAuthFixture, +): + """Test config flow with developer credential registered.""" + client = await ws_client() + + await client.cmd_result( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + result = await oauth_fixture.complete_external_step(result) + assert ( + result["data"].get("auth_implementation") == "fake_integration_some_client_id" + ) + + # Verify it is not possible to delete an in-use config entry + resp = await client.cmd("delete", {"developer_credentials_id": ID}) + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "unknown_error" + + +async def test_config_flow_multiple_entries( + hass: HomeAssistant, + ws_client: ClientFixture, + oauth_fixture: OAuthFixture, +): + """Test config flow with multiple developer credentials registered.""" + client = await ws_client() + + await client.cmd_result( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + await client.cmd_result( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID + "2", + CONF_CLIENT_SECRET: CLIENT_SECRET + "2", + }, + ) + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result.get("step_id") == "pick_implementation" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"implementation": "fake_integration_some_client_id2"}, + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + oauth_fixture.client_id = CLIENT_ID + "2" + result = await oauth_fixture.complete_external_step(result) + assert ( + result["data"].get("auth_implementation") == "fake_integration_some_client_id2" + ) + + +async def test_config_flow_create_delete_credential( + hass: HomeAssistant, + ws_client: ClientFixture, + oauth_fixture: OAuthFixture, +): + """Test adding and deleting a credential unregisters from the config flow.""" + client = await ws_client() + + await client.cmd_result( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + await client.cmd("delete", {"developer_credentials_id": ID}) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "missing_configuration" + + +@pytest.mark.parametrize("config_credential", [DEVELOPER_CREDENTIAL]) +async def test_config_flow_with_config_credential( + hass, hass_client_no_auth, aioclient_mock, oauth_fixture: OAuthFixture +): + """Test config flow with developer credential registered.""" + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + result = await oauth_fixture.complete_external_step(result) + assert result["data"].get("auth_implementation") == ID From b21851f8f91b85ad5003cbda9e25791d801b5b3d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Apr 2022 13:50:34 -0700 Subject: [PATCH 02/37] Fix pydoc text --- homeassistant/components/developer_credentials/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/developer_credentials/__init__.py b/homeassistant/components/developer_credentials/__init__.py index e7174908aaf9b..47d8d755450e6 100644 --- a/homeassistant/components/developer_credentials/__init__.py +++ b/homeassistant/components/developer_credentials/__init__.py @@ -64,7 +64,7 @@ class AuthorizationServer: class DeveloperCredentialsStorageCollection(collection.StorageCollection): - """Input boolean collection stored in storage.""" + """Developer credential collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) From 8000d0a1474743b7691a793373787109be98ac49 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Apr 2022 21:41:47 -0700 Subject: [PATCH 03/37] Remove translations and update owners --- CODEOWNERS | 4 ++-- .../developer_credentials/manifest.json | 2 +- .../translations/en.json | 21 ------------------- 3 files changed, 3 insertions(+), 24 deletions(-) delete mode 100644 homeassistant/components/developer_credentials/translations/en.json diff --git a/CODEOWNERS b/CODEOWNERS index 7b87fb1b0bd5a..5d712edb6691c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -216,8 +216,8 @@ build.json @home-assistant/supervisor /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney /tests/components/derivative/ @afaucogney -/homeassistant/components/developer_credentials/ @allenporter -/tests/components/developer_credentials/ @allenporter +/homeassistant/components/developer_credentials/ @home-assistant/core +/tests/components/developer_credentials/ @home-assistant/core /homeassistant/components/device_automation/ @home-assistant/core /tests/components/device_automation/ @home-assistant/core /homeassistant/components/device_tracker/ @home-assistant/core diff --git a/homeassistant/components/developer_credentials/manifest.json b/homeassistant/components/developer_credentials/manifest.json index f602775e5db84..ac38e5834e2fc 100644 --- a/homeassistant/components/developer_credentials/manifest.json +++ b/homeassistant/components/developer_credentials/manifest.json @@ -4,6 +4,6 @@ "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/developer_credentials", "dependencies": ["http"], - "codeowners": ["@allenporter"], + "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } diff --git a/homeassistant/components/developer_credentials/translations/en.json b/homeassistant/components/developer_credentials/translations/en.json deleted file mode 100644 index f15fe84c3ed6e..0000000000000 --- a/homeassistant/components/developer_credentials/translations/en.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "host": "Host", - "password": "Password", - "username": "Username" - } - } - } - } -} \ No newline at end of file From e24149465154521c0fc3ad508e26d021684816e7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Apr 2022 21:38:05 -0700 Subject: [PATCH 04/37] Update homeassistant/components/developer_credentials/__init__.py Co-authored-by: Paulus Schoutsen --- homeassistant/components/developer_credentials/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/developer_credentials/__init__.py b/homeassistant/components/developer_credentials/__init__.py index 47d8d755450e6..8f36ea17467af 100644 --- a/homeassistant/components/developer_credentials/__init__.py +++ b/homeassistant/components/developer_credentials/__init__.py @@ -196,7 +196,7 @@ def _async_unregister_auth_implementation( ) -> None: """Register an OAuth2 flow implementation for an integration.""" auth_domain = _get_auth_domain(domain, developer_credential) - unsub = hass.data[DOMAIN][domain][auth_domain] + unsub = hass.data[DOMAIN][domain].pop(auth_domain) unsub() From e9aecf02ded606554a89b4cfb566d51bcd2f6d66 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Apr 2022 21:45:51 -0700 Subject: [PATCH 05/37] Update homeassistant/components/developer_credentials/__init__.py Co-authored-by: Paulus Schoutsen --- homeassistant/components/developer_credentials/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/developer_credentials/__init__.py b/homeassistant/components/developer_credentials/__init__.py index 8f36ea17467af..64ca9514c314a 100644 --- a/homeassistant/components/developer_credentials/__init__.py +++ b/homeassistant/components/developer_credentials/__init__.py @@ -174,6 +174,8 @@ async def _async_register_auth_implementation( ) -> None: """Register an OAuth2 flow implementation for an integration.""" auth_domain = _get_auth_domain(domain, developer_credential) + if auth_domain in hass.data[DOMAIN][domain]: + raise ValueError(f"Domain {auth_domain} already registered") authorization_server = await _async_get_authorization_server(hass, domain) if not authorization_server: raise ValueError("No authorization server registered for %s" % domain) From db7c7637f3cb23429a66018580735c93bb8e515c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Apr 2022 21:49:36 -0700 Subject: [PATCH 06/37] Remove _async_get_developer_credential --- .../components/developer_credentials/__init__.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/homeassistant/components/developer_credentials/__init__.py b/homeassistant/components/developer_credentials/__init__.py index 64ca9514c314a..7fa5c24ec7e17 100644 --- a/homeassistant/components/developer_credentials/__init__.py +++ b/homeassistant/components/developer_credentials/__init__.py @@ -217,18 +217,6 @@ async def _async_get_authorization_server( return authorization_server -async def _async_get_developer_credential( - hass: HomeAssistant, domain: str -) -> DeveloperCredential | None: - """Return the DeveloperCredential for the integration domain.""" - if domain not in hass.data[DOMAIN]: - return None - get_developer_credential = hass.data[DOMAIN][domain][ - DeveloperCredentialsType.CONFIG_CREDENTIAL.value - ] - return await get_developer_credential(hass) - - class DeveloperCredentialsProtocol(Protocol): """Define the format that developer_credentials platforms can have.""" @@ -262,9 +250,7 @@ async def _register_developer_credentials_platform( # Register an authentication implementation for every credential provided if get_config_developer_credential is None: return - developer_credential = await _async_get_developer_credential( - hass, integration_domain - ) + developer_credential = await get_config_developer_credential(hass) if not developer_credential: return await _async_register_auth_implementation( From 2df100a9ca5a7f2e37efabfcaae3cf1029e22fb1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Apr 2022 11:13:25 -0700 Subject: [PATCH 07/37] Rename to application credentials platform --- CODEOWNERS | 4 +- .../__init__.py | 92 +++++++++---------- .../application_credentials/const.py | 12 +++ .../application_credentials/manifest.json | 9 ++ .../application_credentials/strings.json | 3 + .../components/developer_credentials/const.py | 12 --- .../developer_credentials/manifest.json | 9 -- .../developer_credentials/strings.json | 3 - script/hassfest/manifest.py | 2 +- .../application_credentials/__init__.py | 1 + .../test_init.py | 44 ++++----- .../developer_credentials/__init__.py | 1 - 12 files changed, 95 insertions(+), 97 deletions(-) rename homeassistant/components/{developer_credentials => application_credentials}/__init__.py (71%) create mode 100644 homeassistant/components/application_credentials/const.py create mode 100644 homeassistant/components/application_credentials/manifest.json create mode 100644 homeassistant/components/application_credentials/strings.json delete mode 100644 homeassistant/components/developer_credentials/const.py delete mode 100644 homeassistant/components/developer_credentials/manifest.json delete mode 100644 homeassistant/components/developer_credentials/strings.json create mode 100644 tests/components/application_credentials/__init__.py rename tests/components/{developer_credentials => application_credentials}/test_init.py (89%) delete mode 100644 tests/components/developer_credentials/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 5d712edb6691c..b8d737cdfb7d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -75,6 +75,8 @@ build.json @home-assistant/supervisor /tests/components/api/ @home-assistant/core /homeassistant/components/apple_tv/ @postlund /tests/components/apple_tv/ @postlund +/homeassistant/components/application_credentials/ @home-assistant/core +/tests/components/application_credentials/ @home-assistant/core /homeassistant/components/apprise/ @caronc /tests/components/apprise/ @caronc /homeassistant/components/aprs/ @PhilRW @@ -216,8 +218,6 @@ build.json @home-assistant/supervisor /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney /tests/components/derivative/ @afaucogney -/homeassistant/components/developer_credentials/ @home-assistant/core -/tests/components/developer_credentials/ @home-assistant/core /homeassistant/components/device_automation/ @home-assistant/core /tests/components/device_automation/ @home-assistant/core /homeassistant/components/device_tracker/ @home-assistant/core diff --git a/homeassistant/components/developer_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py similarity index 71% rename from homeassistant/components/developer_credentials/__init__.py rename to homeassistant/components/application_credentials/__init__.py index 7fa5c24ec7e17..2f2faf1c52544 100644 --- a/homeassistant/components/developer_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -1,4 +1,4 @@ -"""The Developer Credentials integration. +"""The Application Credentials integration. This integration provides APIs for managing local OAuth credentials on behalf of other integrations. The preferred approach for all OAuth integrations is to use the cloud @@ -6,7 +6,7 @@ it. Integrations register an authorization server, and then the APIs are used to add -add one or more developer credentials. Integrations may also provide credentials +add one or more client credentials. Integrations may also provide credentials from yaml for backwards compatibility. """ from __future__ import annotations @@ -30,9 +30,9 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify -from .const import DOMAIN, DeveloperCredentialsType +from .const import DOMAIN, ApplicationCredentialsType -__all__ = ["DeveloperCredential", "AuthorizationServer"] +__all__ = ["ClientCredential", "AuthorizationServer"] _LOGGER = logging.getLogger(__name__) @@ -48,8 +48,8 @@ @dataclass -class DeveloperCredential: - """Represent a developer credential.""" +class ClientCredential: + """Represent an OAuth client credential.""" client_id: str client_secret: str @@ -63,8 +63,8 @@ class AuthorizationServer: token_url: str -class DeveloperCredentialsStorageCollection(collection.StorageCollection): - """Developer credential collection stored in storage.""" +class ApplicationCredentialsStorageCollection(collection.StorageCollection): + """Application credential collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -101,11 +101,11 @@ async def async_delete_item(self, item_id: str) -> None: await super().async_delete_item(item_id) -class DeveloperCredentialsStorageListener: +class ApplicationCredentialsStorageListener: """Listener that handles registering and unregistering OAuth implementations for credentials.""" def __init__(self, hass: HomeAssistant) -> None: - """Initialize DeveloperCredentialsStorageListener.""" + """Initialize ApplicationCredentialsStorageListener.""" self.hass = hass async def updated(self, change_type: str, item_id: str, config: dict): @@ -114,35 +114,35 @@ async def updated(self, change_type: str, item_id: str, config: dict): # not expected return integration_domain = config[CONF_DOMAIN] - developer_credential = DeveloperCredential( + credential = ClientCredential( config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET] ) if change_type == collection.CHANGE_REMOVED: _async_unregister_auth_implementation( - self.hass, integration_domain, developer_credential + self.hass, integration_domain, credential ) else: await _async_register_auth_implementation( - self.hass, integration_domain, developer_credential + self.hass, integration_domain, credential ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Developer Credentials.""" + """Set up Application Credentials.""" hass.data[DOMAIN] = {} await integration_platform.async_process_integration_platforms( - hass, DOMAIN, _register_developer_credentials_platform + hass, DOMAIN, _register_application_credentials_platform ) - # Developer Credentials from storage + # Credentials from storage id_manager = collection.IDManager() - storage_collection = DeveloperCredentialsStorageCollection( + storage_collection = ApplicationCredentialsStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - storage_listener = DeveloperCredentialsStorageListener(hass) + storage_listener = ApplicationCredentialsStorageListener(hass) storage_collection.async_add_listener(storage_listener.updated) await storage_collection.async_load() @@ -156,24 +156,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -# Creates AbstractOAuth2Implementation given a DeveloperCredential for a specific domain +# Creates AbstractOAuth2Implementation given a ClientCredential for a specific domain AuthImplFactory = Callable[ - [str, DeveloperCredential], config_entry_oauth2_flow.AbstractOAuth2Implementation + [str, ClientCredential], config_entry_oauth2_flow.AbstractOAuth2Implementation ] -def _get_auth_domain(domain: str, developer_credential: DeveloperCredential) -> str: +def _get_auth_domain(domain: str, credential: ClientCredential) -> str: """Return the OAuth2 flow implementation domain.""" - return slugify(f"{domain}.{developer_credential.client_id}") + return slugify(f"{domain}.{credential.client_id}") async def _async_register_auth_implementation( hass: HomeAssistant, domain: str, - developer_credential: DeveloperCredential, + credential: ClientCredential, ) -> None: """Register an OAuth2 flow implementation for an integration.""" - auth_domain = _get_auth_domain(domain, developer_credential) + auth_domain = _get_auth_domain(domain, credential) if auth_domain in hass.data[DOMAIN][domain]: raise ValueError(f"Domain {auth_domain} already registered") authorization_server = await _async_get_authorization_server(hass, domain) @@ -182,8 +182,8 @@ async def _async_register_auth_implementation( auth_impl = config_entry_oauth2_flow.LocalOAuth2Implementation( hass, auth_domain, - developer_credential.client_id, - developer_credential.client_secret, + credential.client_id, + credential.client_secret, authorization_server.authorize_url, authorization_server.token_url, ) @@ -194,10 +194,10 @@ async def _async_register_auth_implementation( def _async_unregister_auth_implementation( - hass: HomeAssistant, domain: str, developer_credential: DeveloperCredential + hass: HomeAssistant, domain: str, credential: ClientCredential ) -> None: """Register an OAuth2 flow implementation for an integration.""" - auth_domain = _get_auth_domain(domain, developer_credential) + auth_domain = _get_auth_domain(domain, credential) unsub = hass.data[DOMAIN][domain].pop(auth_domain) unsub() @@ -209,7 +209,7 @@ async def _async_get_authorization_server( if domain not in hass.data[DOMAIN]: return None get_authorization_server = hass.data[DOMAIN][domain][ - DeveloperCredentialsType.AUTHORIZATION_SERVER.value + ApplicationCredentialsType.AUTHORIZATION_SERVER.value ] authorization_server = await get_authorization_server(hass) if not authorization_server: @@ -217,42 +217,40 @@ async def _async_get_authorization_server( return authorization_server -class DeveloperCredentialsProtocol(Protocol): - """Define the format that developer_credentials platforms can have.""" +class ApplicationCredentialsProtocol(Protocol): + """Define the format that application_credentials platforms can have.""" async def async_get_authorization_server( self, hass: HomeAssistant ) -> AuthorizationServer: """Return authorization server.""" - async def async_get_developer_credential( + async def async_get_client_credential( self, hass: HomeAssistant - ) -> DeveloperCredential: - """Return a developer credential from configuration.yaml.""" + ) -> ClientCredential: + """Return a client credential from configuration.yaml.""" -async def _register_developer_credentials_platform( +async def _register_application_credentials_platform( hass: HomeAssistant, integration_domain: str, - platform: DeveloperCredentialsProtocol, + platform: ApplicationCredentialsProtocol, ): - """Register a developer_credentials platform.""" + """Register an application_credentials platform.""" get_authorization_server = getattr(platform, "async_get_authorization_server", None) if get_authorization_server is None: return - get_config_developer_credential = getattr( - platform, "async_get_developer_credential", None + get_config_client_credential = getattr( + platform, "async_get_client_credential", None ) hass.data[DOMAIN][integration_domain] = { - DeveloperCredentialsType.AUTHORIZATION_SERVER.value: get_authorization_server, - DeveloperCredentialsType.CONFIG_CREDENTIAL.value: get_config_developer_credential, + ApplicationCredentialsType.AUTHORIZATION_SERVER.value: get_authorization_server, + ApplicationCredentialsType.CONFIG_CREDENTIAL.value: get_config_client_credential, } # Register an authentication implementation for every credential provided - if get_config_developer_credential is None: + if get_config_client_credential is None: return - developer_credential = await get_config_developer_credential(hass) - if not developer_credential: + credential = await get_config_client_credential(hass) + if not credential: return - await _async_register_auth_implementation( - hass, integration_domain, developer_credential - ) + await _async_register_auth_implementation(hass, integration_domain, credential) diff --git a/homeassistant/components/application_credentials/const.py b/homeassistant/components/application_credentials/const.py new file mode 100644 index 0000000000000..a00c74eb071ae --- /dev/null +++ b/homeassistant/components/application_credentials/const.py @@ -0,0 +1,12 @@ +"""Constants for the Application Credentials integration.""" + +from homeassistant.backports.enum import StrEnum + +DOMAIN = "application_credentials" + + +class ApplicationCredentialsType(StrEnum): + """Application Credentials types.""" + + AUTHORIZATION_SERVER = "authorization_server" + CONFIG_CREDENTIAL = "config_credential" diff --git a/homeassistant/components/application_credentials/manifest.json b/homeassistant/components/application_credentials/manifest.json new file mode 100644 index 0000000000000..6c03ade86c976 --- /dev/null +++ b/homeassistant/components/application_credentials/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "application_credentials", + "name": "Application Credentials", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/application_credentials", + "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/application_credentials/strings.json b/homeassistant/components/application_credentials/strings.json new file mode 100644 index 0000000000000..48d74bc75e42d --- /dev/null +++ b/homeassistant/components/application_credentials/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Application Credentials" +} diff --git a/homeassistant/components/developer_credentials/const.py b/homeassistant/components/developer_credentials/const.py deleted file mode 100644 index 93c6fac75a43f..0000000000000 --- a/homeassistant/components/developer_credentials/const.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Constants for the Developer Credentials integration.""" - -from homeassistant.backports.enum import StrEnum - -DOMAIN = "developer_credentials" - - -class DeveloperCredentialsType(StrEnum): - """Developer Credentials types.""" - - AUTHORIZATION_SERVER = "authorization_server" - CONFIG_CREDENTIAL = "config_credential" diff --git a/homeassistant/components/developer_credentials/manifest.json b/homeassistant/components/developer_credentials/manifest.json deleted file mode 100644 index ac38e5834e2fc..0000000000000 --- a/homeassistant/components/developer_credentials/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "developer_credentials", - "name": "Developer Credentials", - "config_flow": false, - "documentation": "https://www.home-assistant.io/integrations/developer_credentials", - "dependencies": ["http"], - "codeowners": ["@home-assistant/core"], - "quality_scale": "internal" -} diff --git a/homeassistant/components/developer_credentials/strings.json b/homeassistant/components/developer_credentials/strings.json deleted file mode 100644 index 86cb8a690d971..0000000000000 --- a/homeassistant/components/developer_credentials/strings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Developer Credentials" -} diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 5cf2fd01a2ada..b66b33486cb8b 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -36,6 +36,7 @@ NO_IOT_CLASS = [ *{platform.value for platform in Platform}, "api", + "application_credentials", "auth", "automation", "blueprint", @@ -44,7 +45,6 @@ "configurator", "counter", "default_config", - "developer_credentials", "device_automation", "device_tracker", "diagnostics", diff --git a/tests/components/application_credentials/__init__.py b/tests/components/application_credentials/__init__.py new file mode 100644 index 0000000000000..36933b9ccfbfc --- /dev/null +++ b/tests/components/application_credentials/__init__.py @@ -0,0 +1 @@ +"""Tests for the Application Credentials integration.""" diff --git a/tests/components/developer_credentials/test_init.py b/tests/components/application_credentials/test_init.py similarity index 89% rename from tests/components/developer_credentials/test_init.py rename to tests/components/application_credentials/test_init.py index c822100279dd0..5c7d9472d1154 100644 --- a/tests/components/developer_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -11,11 +11,11 @@ import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components.developer_credentials import ( +from homeassistant.components.application_credentials import ( AuthorizationServer, - DeveloperCredential, + ClientCredential, ) -from homeassistant.components.developer_credentials.const import DOMAIN +from homeassistant.components.application_credentials.const import DOMAIN from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow @@ -25,7 +25,7 @@ CLIENT_ID = "some-client-id" CLIENT_SECRET = "some-client-secret" -DEVELOPER_CREDENTIAL = DeveloperCredential(CLIENT_ID, CLIENT_SECRET) +DEVELOPER_CREDENTIAL = ClientCredential(CLIENT_ID, CLIENT_SECRET) ID = "fake_integration_some_client_id" AUTHORIZE_URL = "https://example.com/auth" TOKEN_URL = "https://example.com/oauth2/v4/token" @@ -37,31 +37,31 @@ @pytest.fixture async def authorization_server() -> AuthorizationServer: - """Fixture AuthorizationServer for mock developer_credentials integration.""" + """Fixture AuthorizationServer for mock application_credentials integration.""" return AuthorizationServer(AUTHORIZE_URL, TOKEN_URL) @pytest.fixture -async def config_credential() -> DeveloperCredential | None: - """Fixture DeveloperCredential for mock developer_credentials integration.""" +async def config_credential() -> ClientCredential | None: + """Fixture ClientCredential for mock application_credentials integration.""" return None @pytest.fixture(autouse=True) -async def mock_developer_credentials_integration( - hass, authorization_server, config_credential: DeveloperCredential | None +async def mock_application_credentials_integration( + hass, authorization_server, config_credential: ClientCredential | None ): - """Mock a developer_credentials integration.""" + """Mock a application_credentials integration.""" hass.config.components.add(TEST_DOMAIN) mock_platform( hass, - f"{TEST_DOMAIN}.developer_credentials", + f"{TEST_DOMAIN}.application_credentials", Mock( async_get_authorization_server=AsyncMock(return_value=authorization_server), - async_get_developer_credential=AsyncMock(return_value=config_credential), + async_get_client_credential=AsyncMock(return_value=config_credential), ), ) - assert await async_setup_component(hass, "developer_credentials", {}) + assert await async_setup_component(hass, "application_credentials", {}) class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): @@ -143,7 +143,7 @@ async def oauth_fixture( class Client: - """Test client with helper methods for developer credentials websocket.""" + """Test client with helper methods for application credentials websocket.""" def __init__(self, client): """Initialize Client.""" @@ -261,7 +261,7 @@ async def test_websocket_update_not_supported(ws_client: ClientFixture): "id": ID, } - resp = await client.cmd("update", {"developer_credentials_id": ID}) + resp = await client.cmd("update", {"application_credentials_id": ID}) assert not resp.get("success") assert "error" in resp assert resp["error"].get("code") == "invalid_format" @@ -289,7 +289,7 @@ async def test_websocket_delete(ws_client: ClientFixture): } ] - await client.cmd_result("delete", {"developer_credentials_id": ID}) + await client.cmd_result("delete", {"application_credentials_id": ID}) assert await client.cmd_result("list") == [] @@ -302,7 +302,7 @@ async def test_websocket_yaml_config(ws_client: ClientFixture): assert await client.cmd_result("list") == [] # Configuration based cannot be deleted - resp = await client.cmd("delete", {"developer_credentials_id": ID}) + resp = await client.cmd("delete", {"application_credentials_id": ID}) assert not resp.get("success") assert "error" in resp assert resp["error"].get("code") == "not_found" @@ -322,7 +322,7 @@ async def test_config_flow( ws_client: ClientFixture, oauth_fixture: OAuthFixture, ): - """Test config flow with developer credential registered.""" + """Test config flow with application credential registered.""" client = await ws_client() await client.cmd_result( @@ -343,7 +343,7 @@ async def test_config_flow( ) # Verify it is not possible to delete an in-use config entry - resp = await client.cmd("delete", {"developer_credentials_id": ID}) + resp = await client.cmd("delete", {"application_credentials_id": ID}) assert not resp.get("success") assert "error" in resp assert resp["error"].get("code") == "unknown_error" @@ -354,7 +354,7 @@ async def test_config_flow_multiple_entries( ws_client: ClientFixture, oauth_fixture: OAuthFixture, ): - """Test config flow with multiple developer credentials registered.""" + """Test config flow with multiple application credentials registered.""" client = await ws_client() await client.cmd_result( @@ -407,7 +407,7 @@ async def test_config_flow_create_delete_credential( CONF_CLIENT_SECRET: CLIENT_SECRET, }, ) - await client.cmd("delete", {"developer_credentials_id": ID}) + await client.cmd("delete", {"application_credentials_id": ID}) result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -420,7 +420,7 @@ async def test_config_flow_create_delete_credential( async def test_config_flow_with_config_credential( hass, hass_client_no_auth, aioclient_mock, oauth_fixture: OAuthFixture ): - """Test config flow with developer credential registered.""" + """Test config flow with application credential registered.""" result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/developer_credentials/__init__.py b/tests/components/developer_credentials/__init__.py deleted file mode 100644 index 09e4e6cbe8a8b..0000000000000 --- a/tests/components/developer_credentials/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Developer Credentials integration.""" From 655e821611719f8c82171dfb6956146c15c0419a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 4 Apr 2022 21:58:06 -0700 Subject: [PATCH 08/37] Fix race condition and add import support --- .../application_credentials/__init__.py | 213 +++++++----------- .../application_credentials/const.py | 12 - .../components/cloud/account_link.py | 4 +- .../helpers/config_entry_oauth2_flow.py | 8 +- .../application_credentials/test_init.py | 40 ++-- .../helpers/test_config_entry_oauth2_flow.py | 28 ++- 6 files changed, 140 insertions(+), 165 deletions(-) delete mode 100644 homeassistant/components/application_credentials/const.py diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 2f2faf1c52544..973eb6e733e46 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -11,38 +11,36 @@ """ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass import logging from typing import Protocol import voluptuous as vol -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DOMAIN, CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - collection, - config_entry_oauth2_flow, - integration_platform, -) +from homeassistant.helpers import collection, config_entry_oauth2_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from homeassistant.util import slugify +from homeassistant.loader import IntegrationNotFound, async_get_integration -from .const import DOMAIN, ApplicationCredentialsType - -__all__ = ["ClientCredential", "AuthorizationServer"] +__all__ = ["ClientCredential", "AuthorizationServer", "async_import_client_credential"] _LOGGER = logging.getLogger(__name__) +DOMAIN = "application_credentials" + STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +DATA_STORAGE = "storage" +CONF_AUTH_IMPL = "auth_implementation" CREATE_FIELDS = { vol.Required(CONF_DOMAIN): cv.string, vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_AUTH_IMPL): cv.string, } UPDATE_FIELDS: dict = {} # Not supported @@ -72,9 +70,8 @@ async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" result = self.CREATE_SCHEMA(data) domain = result[CONF_DOMAIN] - authorization_server = await _async_get_authorization_server(self.hass, domain) - if not authorization_server: - raise ValueError("No authorization server registered for %s" % domain) + if not await _get_platform(self.hass, domain): + raise ValueError("No application_credentials platform for %s" % domain) return result @callback @@ -100,121 +97,90 @@ async def async_delete_item(self, item_id: str) -> None: await super().async_delete_item(item_id) - -class ApplicationCredentialsStorageListener: - """Listener that handles registering and unregistering OAuth implementations for credentials.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize ApplicationCredentialsStorageListener.""" - self.hass = hass - - async def updated(self, change_type: str, item_id: str, config: dict): - """Update set of registered authentication implementations.""" - if change_type not in [collection.CHANGE_ADDED, collection.CHANGE_REMOVED]: - # not expected - return - integration_domain = config[CONF_DOMAIN] - credential = ClientCredential( - config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET] - ) - if change_type == collection.CHANGE_REMOVED: - _async_unregister_auth_implementation( - self.hass, integration_domain, credential - ) - else: - await _async_register_auth_implementation( - self.hass, integration_domain, credential + def async_client_credentials(self, domain: str) -> dict[str, ClientCredential]: + """Return ClientCredentials in storage for the specified domain.""" + credentials = {} + for item in self.async_items(): + if item[CONF_DOMAIN] != domain: + continue + auth_domain = item.get(CONF_AUTH_IMPL, item[CONF_ID]) + credentials[auth_domain] = ClientCredential( + item[CONF_CLIENT_ID], item[CONF_CLIENT_SECRET] ) + return credentials async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Application Credentials.""" hass.data[DOMAIN] = {} - await integration_platform.async_process_integration_platforms( - hass, DOMAIN, _register_application_credentials_platform - ) - - # Credentials from storage id_manager = collection.IDManager() storage_collection = ApplicationCredentialsStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) - storage_listener = ApplicationCredentialsStorageListener(hass) - storage_collection.async_add_listener(storage_listener.updated) await storage_collection.async_load() + hass.data[DOMAIN][DATA_STORAGE] = storage_collection collection.StorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - # Allow future registration of local oauth implementations config_entry_oauth2_flow.async_register_local_apis(hass) + config_entry_oauth2_flow.async_add_implementation_provider( + hass, DOMAIN, _async_provide_implementation + ) return True -# Creates AbstractOAuth2Implementation given a ClientCredential for a specific domain -AuthImplFactory = Callable[ - [str, ClientCredential], config_entry_oauth2_flow.AbstractOAuth2Implementation -] - - -def _get_auth_domain(domain: str, credential: ClientCredential) -> str: - """Return the OAuth2 flow implementation domain.""" - return slugify(f"{domain}.{credential.client_id}") - - -async def _async_register_auth_implementation( - hass: HomeAssistant, - domain: str, - credential: ClientCredential, +async def async_import_client_credential( + hass: HomeAssistant, domain: str, auth_domain: str, credential: ClientCredential ) -> None: - """Register an OAuth2 flow implementation for an integration.""" - auth_domain = _get_auth_domain(domain, credential) - if auth_domain in hass.data[DOMAIN][domain]: - raise ValueError(f"Domain {auth_domain} already registered") - authorization_server = await _async_get_authorization_server(hass, domain) - if not authorization_server: - raise ValueError("No authorization server registered for %s" % domain) - auth_impl = config_entry_oauth2_flow.LocalOAuth2Implementation( - hass, - auth_domain, - credential.client_id, - credential.client_secret, - authorization_server.authorize_url, - authorization_server.token_url, - ) - unsub = config_entry_oauth2_flow.async_register_implementation( - hass, domain, auth_impl - ) - hass.data[DOMAIN][domain][auth_domain] = unsub + """Import an existing credential from configuration.yaml. + + The imported credential auth domain should match the existing auth implementation domain used + when creating the LocalOAuth2Implementation, which is also persisted in the ConfigEntry. + """ + if DOMAIN not in hass.data: + raise ValueError("Integration 'application_credentials' not setup") + storage_collection = hass.data[DOMAIN][DATA_STORAGE] + item = { + CONF_DOMAIN: domain, + CONF_CLIENT_ID: credential.client_id, + CONF_CLIENT_SECRET: credential.client_secret, + CONF_AUTH_IMPL: auth_domain, + } + await storage_collection.async_create_item(item) -def _async_unregister_auth_implementation( - hass: HomeAssistant, domain: str, credential: ClientCredential -) -> None: - """Register an OAuth2 flow implementation for an integration.""" - auth_domain = _get_auth_domain(domain, credential) - unsub = hass.data[DOMAIN][domain].pop(auth_domain) - unsub() +async def _async_provide_implementation( + hass: HomeAssistant, domain: str +) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: + """Return registered OAuth implementations.""" + platform = await _get_platform(hass, domain) + if not platform: + return [] -async def _async_get_authorization_server( - hass: HomeAssistant, domain: str -) -> AuthorizationServer | None: - """Return the AuthorizationServer for the integration domain.""" - if domain not in hass.data[DOMAIN]: - return None - get_authorization_server = hass.data[DOMAIN][domain][ - ApplicationCredentialsType.AUTHORIZATION_SERVER.value - ] - authorization_server = await get_authorization_server(hass) + authorization_server = await platform.async_get_authorization_server(hass) if not authorization_server: - return None - return authorization_server + return [] + + storage_collection = hass.data[DOMAIN][DATA_STORAGE] + credentials = storage_collection.async_client_credentials(domain) + return [ + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + credential.client_secret, + authorization_server.authorize_url, + authorization_server.token_url, + ) + for auth_domain, credential in credentials.items() + ] class ApplicationCredentialsProtocol(Protocol): @@ -225,32 +191,27 @@ async def async_get_authorization_server( ) -> AuthorizationServer: """Return authorization server.""" - async def async_get_client_credential( - self, hass: HomeAssistant - ) -> ClientCredential: - """Return a client credential from configuration.yaml.""" - -async def _register_application_credentials_platform( - hass: HomeAssistant, - integration_domain: str, - platform: ApplicationCredentialsProtocol, -): +async def _get_platform( + hass: HomeAssistant, integration_domain: str +) -> ApplicationCredentialsProtocol | None: """Register an application_credentials platform.""" - get_authorization_server = getattr(platform, "async_get_authorization_server", None) - if get_authorization_server is None: - return - get_config_client_credential = getattr( - platform, "async_get_client_credential", None - ) - hass.data[DOMAIN][integration_domain] = { - ApplicationCredentialsType.AUTHORIZATION_SERVER.value: get_authorization_server, - ApplicationCredentialsType.CONFIG_CREDENTIAL.value: get_config_client_credential, - } - # Register an authentication implementation for every credential provided - if get_config_client_credential is None: - return - credential = await get_config_client_credential(hass) - if not credential: - return - await _async_register_auth_implementation(hass, integration_domain, credential) + try: + integration = await async_get_integration(hass, integration_domain) + except IntegrationNotFound as err: + _LOGGER.debug("Integration '%s' does not exist: %s", integration_domain, err) + return None + try: + platform = integration.get_platform("application_credentials") + except ImportError as err: + _LOGGER.debug( + "Integration '%s' does not provide application_credentials: %s", + integration_domain, + err, + ) + return None + if not hasattr(platform, "async_get_authorization_server"): + raise ValueError( + "Integration '%s' platform application_credentials did not implement 'async_get_authorization_server'" + ) + return platform diff --git a/homeassistant/components/application_credentials/const.py b/homeassistant/components/application_credentials/const.py deleted file mode 100644 index a00c74eb071ae..0000000000000 --- a/homeassistant/components/application_credentials/const.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Constants for the Application Credentials integration.""" - -from homeassistant.backports.enum import StrEnum - -DOMAIN = "application_credentials" - - -class ApplicationCredentialsType(StrEnum): - """Application Credentials types.""" - - AUTHORIZATION_SERVER = "authorization_server" - CONFIG_CREDENTIAL = "config_credential" diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 6dc0da82512bd..5df16cb17245b 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -34,9 +34,9 @@ async def async_provide_implementation(hass: HomeAssistant, domain: str): for service in services: if service["service"] == domain and CURRENT_VERSION >= service["min_version"]: - return CloudOAuth2Implementation(hass, domain) + return [CloudOAuth2Implementation(hass, domain)] - return + return [] async def _get_services(hass): diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index dd5156e39ea9b..332caddbc0bcc 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -353,9 +353,9 @@ async def async_get_implementations( registered = dict(registered) - for provider_domain, get_impl in hass.data[DATA_PROVIDERS].items(): - if (implementation := await get_impl(hass, domain)) is not None: - registered[provider_domain] = implementation + for get_impl in hass.data[DATA_PROVIDERS].values(): + for impl in await get_impl(hass, domain): + registered[impl.domain] = impl return registered @@ -378,7 +378,7 @@ def async_add_implementation_provider( hass: HomeAssistant, provider_domain: str, async_provide_implementation: Callable[ - [HomeAssistant, str], Awaitable[AbstractOAuth2Implementation | None] + [HomeAssistant, str], Awaitable[list[AbstractOAuth2Implementation]] ], ) -> None: """Add an implementation provider. diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 5c7d9472d1154..5cfb1ecf48c06 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -12,10 +12,12 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.application_credentials import ( + CONF_AUTH_IMPL, + DOMAIN, AuthorizationServer, ClientCredential, + async_import_client_credential, ) -from homeassistant.components.application_credentials.const import DOMAIN from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow @@ -25,6 +27,7 @@ CLIENT_ID = "some-client-id" CLIENT_SECRET = "some-client-secret" +AUTH_DOMAIN = "some-auth-domain" DEVELOPER_CREDENTIAL = ClientCredential(CLIENT_ID, CLIENT_SECRET) ID = "fake_integration_some_client_id" AUTHORIZE_URL = "https://example.com/auth" @@ -52,16 +55,19 @@ async def mock_application_credentials_integration( hass, authorization_server, config_credential: ClientCredential | None ): """Mock a application_credentials integration.""" + assert await async_setup_component(hass, "application_credentials", {}) hass.config.components.add(TEST_DOMAIN) mock_platform( hass, f"{TEST_DOMAIN}.application_credentials", Mock( async_get_authorization_server=AsyncMock(return_value=authorization_server), - async_get_client_credential=AsyncMock(return_value=config_credential), ), ) - assert await async_setup_component(hass, "application_credentials", {}) + if config_credential: + await async_import_client_credential( + hass, TEST_DOMAIN, AUTH_DOMAIN, config_credential + ) class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): @@ -239,7 +245,7 @@ async def test_websocket_create_invalid_domain(ws_client: ClientFixture): assert resp["error"].get("code") == "invalid_format" assert ( resp["error"].get("message") - == "No authorization server registered for other-domain" + == "No application_credentials platform for other-domain" ) @@ -294,18 +300,24 @@ async def test_websocket_delete(ws_client: ClientFixture): @pytest.mark.parametrize("config_credential", [DEVELOPER_CREDENTIAL]) -async def test_websocket_yaml_config(ws_client: ClientFixture): - """Test websocket list command for a yaml based credential.""" +async def test_websocket_import_config(ws_client: ClientFixture): + """Test websocket list command for an imported credential.""" client = await ws_client() - # Configuration based creds are not returned from the websocket - assert await client.cmd_result("list") == [] + # Imported creds returned from websocket + assert await client.cmd_result("list") == [ + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + CONF_AUTH_IMPL: AUTH_DOMAIN, + "id": ID, + } + ] - # Configuration based cannot be deleted - resp = await client.cmd("delete", {"application_credentials_id": ID}) - assert not resp.get("success") - assert "error" in resp - assert resp["error"].get("code") == "not_found" + # Imported credential can be deleted + await client.cmd_result("delete", {"application_credentials_id": ID}) + assert await client.cmd_result("list") == [] async def test_config_flow_no_credentials(hass): @@ -426,4 +438,4 @@ async def test_config_flow_with_config_credential( ) assert result.get("type") == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP result = await oauth_fixture.complete_external_step(result) - assert result["data"].get("auth_implementation") == ID + assert result["data"].get("auth_implementation") == AUTH_DOMAIN diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 2cd3184b44b7a..97e728d022d82 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -537,11 +537,11 @@ async def test_implementation_provider(hass, local_impl): hass, mock_domain_with_impl ) == {TEST_DOMAIN: local_impl} - provider_source = {} + provider_source = [] async def async_provide_implementation(hass, domain): """Mock implementation provider.""" - return provider_source.get(domain) + return provider_source config_entry_oauth2_flow.async_add_implementation_provider( hass, "cloud", async_provide_implementation @@ -551,15 +551,29 @@ async def async_provide_implementation(hass, domain): hass, mock_domain_with_impl ) == {TEST_DOMAIN: local_impl} - provider_source[ - mock_domain_with_impl - ] = config_entry_oauth2_flow.LocalOAuth2Implementation( - hass, "cloud", CLIENT_ID, CLIENT_SECRET, AUTHORIZE_URL, TOKEN_URL + provider_source.append( + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, "cloud", CLIENT_ID, CLIENT_SECRET, AUTHORIZE_URL, TOKEN_URL + ) + ) + + assert await config_entry_oauth2_flow.async_get_implementations( + hass, mock_domain_with_impl + ) == {TEST_DOMAIN: local_impl, "cloud": provider_source[0]} + + provider_source.append( + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, "other", CLIENT_ID, CLIENT_SECRET, AUTHORIZE_URL, TOKEN_URL + ) ) assert await config_entry_oauth2_flow.async_get_implementations( hass, mock_domain_with_impl - ) == {TEST_DOMAIN: local_impl, "cloud": provider_source[mock_domain_with_impl]} + ) == { + TEST_DOMAIN: local_impl, + "cloud": provider_source[0], + "other": provider_source[1], + } async def test_oauth_session_refresh_failure( From cd6b9bb56505ea0fee6c34d6bac2ad14359a462c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 5 Apr 2022 20:24:19 -0700 Subject: [PATCH 09/37] Increase code coverage (92%) --- .../application_credentials/test_init.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 5cfb1ecf48c06..b731820df41b0 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -299,6 +299,20 @@ async def test_websocket_delete(ws_client: ClientFixture): assert await client.cmd_result("list") == [] +async def test_websocket_delete_item_not_found(ws_client: ClientFixture): + """Test websocket delete command.""" + client = await ws_client() + + resp = await client.cmd("delete", {"application_credentials_id": ID}) + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "not_found" + assert ( + resp["error"].get("message") + == f"Unable to find application_credentials_id {ID}" + ) + + @pytest.mark.parametrize("config_credential", [DEVELOPER_CREDENTIAL]) async def test_websocket_import_config(ws_client: ClientFixture): """Test websocket list command for an imported credential.""" From 4134ce651827d8a70987e938d8100c1f58e7becc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 5 Apr 2022 20:36:07 -0700 Subject: [PATCH 10/37] Increase test coverage 93% --- .../application_credentials/test_init.py | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index b731820df41b0..2721c9a451c9d 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -50,26 +50,40 @@ async def config_credential() -> ClientCredential | None: return None -@pytest.fixture(autouse=True) -async def mock_application_credentials_integration( - hass, authorization_server, config_credential: ClientCredential | None -): - """Mock a application_credentials integration.""" - assert await async_setup_component(hass, "application_credentials", {}) - hass.config.components.add(TEST_DOMAIN) +async def setup_application_credentials_integration( + hass: HomeAssistant, + domain: str, + authorization_server: AuthorizationServer, + config_credential: ClientCredential | None, +) -> None: + """Set up a fake application_credentials integration.""" + hass.config.components.add(domain) mock_platform( hass, - f"{TEST_DOMAIN}.application_credentials", + f"{domain}.application_credentials", Mock( async_get_authorization_server=AsyncMock(return_value=authorization_server), ), ) if config_credential: await async_import_client_credential( - hass, TEST_DOMAIN, AUTH_DOMAIN, config_credential + hass, domain, AUTH_DOMAIN, config_credential ) +@pytest.fixture(autouse=True) +async def mock_application_credentials_integration( + hass: HomeAssistant, + authorization_server: AuthorizationServer, + config_credential: ClientCredential | None, +): + """Mock a application_credentials integration.""" + assert await async_setup_component(hass, "application_credentials", {}) + await setup_application_credentials_integration( + hass, TEST_DOMAIN, authorization_server, config_credential + ) + + class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow used during tests.""" @@ -343,6 +357,31 @@ async def test_config_flow_no_credentials(hass): assert result.get("reason") == "missing_configuration" +async def test_config_flow_other_domain( + hass: HomeAssistant, + ws_client: ClientFixture, + authorization_server: AuthorizationServer, +): + """Test config flow ignores credentials for another domain.""" + await setup_application_credentials_integration( + hass, "other_domain", authorization_server, config_credential=None + ) + client = await ws_client() + await client.cmd_result( + "create", + { + CONF_DOMAIN: "other_domain", + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "missing_configuration" + + async def test_config_flow( hass: HomeAssistant, ws_client: ClientFixture, From ef35a2872678297f4600d0e578aeffaa97b9ea44 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 5 Apr 2022 20:42:59 -0700 Subject: [PATCH 11/37] Increase test coverage (94%) --- .../application_credentials/test_init.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 2721c9a451c9d..e0fd8c09db32b 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -71,13 +71,22 @@ async def setup_application_credentials_integration( ) +@pytest.fixture +def disable_setup() -> bool: + """Fixture to disable mock credentials integration.""" + return False + + @pytest.fixture(autouse=True) async def mock_application_credentials_integration( + disable_setup: bool, hass: HomeAssistant, authorization_server: AuthorizationServer, config_credential: ClientCredential | None, ): """Mock a application_credentials integration.""" + if disable_setup: + return assert await async_setup_component(hass, "application_credentials", {}) await setup_application_credentials_integration( hass, TEST_DOMAIN, authorization_server, config_credential @@ -492,3 +501,13 @@ async def test_config_flow_with_config_credential( assert result.get("type") == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP result = await oauth_fixture.complete_external_step(result) assert result["data"].get("auth_implementation") == AUTH_DOMAIN + + +@pytest.mark.parametrize("disable_setup", [True]) +async def test_import_without_setupm(hass, config_credential): + """Test import of credentials without setting up the integration.""" + + with pytest.raises(ValueError): + await async_import_client_credential( + hass, TEST_DOMAIN, TEST_DOMAIN, config_credential + ) From f55f3ad7110f8eefe636e0e45bd70cd6f0d45510 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 5 Apr 2022 20:47:54 -0700 Subject: [PATCH 12/37] Increase test coverage (97%) --- .../application_credentials/test_init.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index e0fd8c09db32b..f49094da49c8c 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -511,3 +511,25 @@ async def test_import_without_setupm(hass, config_credential): await async_import_client_credential( hass, TEST_DOMAIN, TEST_DOMAIN, config_credential ) + + +@pytest.mark.parametrize("disable_setup", [True]) +async def test_websocket_without_platform( + hass: HomeAssistant, ws_client: ClientFixture +): + """Test websocket list command.""" + assert await async_setup_component(hass, "application_credentials", {}) + hass.config.components.add(TEST_DOMAIN) + + client = await ws_client() + resp = await client.cmd( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "invalid_format" From c4b83638fa386a7b23e4edf5c055ab90677efcfc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 5 Apr 2022 20:54:52 -0700 Subject: [PATCH 13/37] Increase test covearge (98%) --- .../application_credentials/__init__.py | 1 + .../application_credentials/test_init.py | 33 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 973eb6e733e46..c44cb72b5d454 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -210,6 +210,7 @@ async def _get_platform( err, ) return None + _LOGGER.debug(hasattr(platform, "async_get_authorization_server")) if not hasattr(platform, "async_get_authorization_server"): raise ValueError( "Integration '%s' platform application_credentials did not implement 'async_get_authorization_server'" diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index f49094da49c8c..a5cdd39024370 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -517,7 +517,7 @@ async def test_import_without_setupm(hass, config_credential): async def test_websocket_without_platform( hass: HomeAssistant, ws_client: ClientFixture ): - """Test websocket list command.""" + """Test an integration without the application credential platform.""" assert await async_setup_component(hass, "application_credentials", {}) hass.config.components.add(TEST_DOMAIN) @@ -533,3 +533,34 @@ async def test_websocket_without_platform( assert not resp.get("success") assert "error" in resp assert resp["error"].get("code") == "invalid_format" + + +@pytest.mark.parametrize("disable_setup", [True]) +async def test_websocket_without_authorization_server( + hass: HomeAssistant, ws_client: ClientFixture +): + """Test platform with incorrect implementation.""" + assert await async_setup_component(hass, "application_credentials", {}) + hass.config.components.add(TEST_DOMAIN) + + # Platform does not implemenent async_get_authorization_server + platform = Mock() + del platform.async_get_authorization_server + mock_platform( + hass, + f"{TEST_DOMAIN}.application_credentials", + platform, + ) + + client = await ws_client() + resp = await client.cmd( + "create", + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + ) + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "invalid_format" From 5d0d0101e3e25fb14044f71f3653b81dcf924044 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 5 Apr 2022 20:58:15 -0700 Subject: [PATCH 14/37] Increase test coverage (99%) --- homeassistant/components/application_credentials/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index c44cb72b5d454..b896d1cad3cd5 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -165,9 +165,6 @@ async def _async_provide_implementation( return [] authorization_server = await platform.async_get_authorization_server(hass) - if not authorization_server: - return [] - storage_collection = hass.data[DOMAIN][DATA_STORAGE] credentials = storage_collection.async_client_credentials(domain) return [ From 095e2dfe71da5c42f7bd18f7ada1fe0983cc3fc8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 5 Apr 2022 21:02:43 -0700 Subject: [PATCH 15/37] Increase test coverage (100%) --- .../application_credentials/test_init.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index a5cdd39024370..075dc8df5bc2f 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -504,7 +504,7 @@ async def test_config_flow_with_config_credential( @pytest.mark.parametrize("disable_setup", [True]) -async def test_import_without_setupm(hass, config_credential): +async def test_import_without_setup(hass, config_credential): """Test import of credentials without setting up the integration.""" with pytest.raises(ValueError): @@ -512,6 +512,13 @@ async def test_import_without_setupm(hass, config_credential): hass, TEST_DOMAIN, TEST_DOMAIN, config_credential ) + # Config flow does not have authentication + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "missing_configuration" + @pytest.mark.parametrize("disable_setup", [True]) async def test_websocket_without_platform( @@ -534,6 +541,13 @@ async def test_websocket_without_platform( assert "error" in resp assert resp["error"].get("code") == "invalid_format" + # Config flow does not have authentication + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "missing_configuration" + @pytest.mark.parametrize("disable_setup", [True]) async def test_websocket_without_authorization_server( @@ -564,3 +578,9 @@ async def test_websocket_without_authorization_server( assert not resp.get("success") assert "error" in resp assert resp["error"].get("code") == "invalid_format" + + # Config flow does not have authentication + with pytest.raises(ValueError): + await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) From 25aa45e1959524d2961af5c3a844f139211461a3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 6 Apr 2022 07:40:26 -0700 Subject: [PATCH 16/37] Remove http router frozen comment --- homeassistant/components/application_credentials/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index b896d1cad3cd5..0ab220adbffc1 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -127,7 +127,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - config_entry_oauth2_flow.async_register_local_apis(hass) config_entry_oauth2_flow.async_add_implementation_provider( hass, DOMAIN, _async_provide_implementation ) From 53a2f4f38930d37bda4a92c9f280393cda49eeed Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 6 Apr 2022 07:49:06 -0700 Subject: [PATCH 17/37] Remove auth domain override on import --- .../components/application_credentials/__init__.py | 14 +++----------- .../application_credentials/test_init.py | 13 +++---------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 0ab220adbffc1..1f8f99ba2f3b4 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -34,13 +34,11 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 DATA_STORAGE = "storage" -CONF_AUTH_IMPL = "auth_implementation" CREATE_FIELDS = { vol.Required(CONF_DOMAIN): cv.string, vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_AUTH_IMPL): cv.string, } UPDATE_FIELDS: dict = {} # Not supported @@ -103,8 +101,7 @@ def async_client_credentials(self, domain: str) -> dict[str, ClientCredential]: for item in self.async_items(): if item[CONF_DOMAIN] != domain: continue - auth_domain = item.get(CONF_AUTH_IMPL, item[CONF_ID]) - credentials[auth_domain] = ClientCredential( + credentials[item[CONF_ID]] = ClientCredential( item[CONF_CLIENT_ID], item[CONF_CLIENT_SECRET] ) return credentials @@ -135,13 +132,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_import_client_credential( - hass: HomeAssistant, domain: str, auth_domain: str, credential: ClientCredential + hass: HomeAssistant, domain: str, credential: ClientCredential ) -> None: - """Import an existing credential from configuration.yaml. - - The imported credential auth domain should match the existing auth implementation domain used - when creating the LocalOAuth2Implementation, which is also persisted in the ConfigEntry. - """ + """Import an existing credential from configuration.yaml.""" if DOMAIN not in hass.data: raise ValueError("Integration 'application_credentials' not setup") storage_collection = hass.data[DOMAIN][DATA_STORAGE] @@ -149,7 +142,6 @@ async def async_import_client_credential( CONF_DOMAIN: domain, CONF_CLIENT_ID: credential.client_id, CONF_CLIENT_SECRET: credential.client_secret, - CONF_AUTH_IMPL: auth_domain, } await storage_collection.async_create_item(item) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 075dc8df5bc2f..eab3d3f85d887 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -12,7 +12,6 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.application_credentials import ( - CONF_AUTH_IMPL, DOMAIN, AuthorizationServer, ClientCredential, @@ -27,7 +26,6 @@ CLIENT_ID = "some-client-id" CLIENT_SECRET = "some-client-secret" -AUTH_DOMAIN = "some-auth-domain" DEVELOPER_CREDENTIAL = ClientCredential(CLIENT_ID, CLIENT_SECRET) ID = "fake_integration_some_client_id" AUTHORIZE_URL = "https://example.com/auth" @@ -66,9 +64,7 @@ async def setup_application_credentials_integration( ), ) if config_credential: - await async_import_client_credential( - hass, domain, AUTH_DOMAIN, config_credential - ) + await async_import_client_credential(hass, domain, config_credential) @pytest.fixture @@ -347,7 +343,6 @@ async def test_websocket_import_config(ws_client: ClientFixture): CONF_DOMAIN: TEST_DOMAIN, CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_AUTH_IMPL: AUTH_DOMAIN, "id": ID, } ] @@ -500,7 +495,7 @@ async def test_config_flow_with_config_credential( ) assert result.get("type") == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP result = await oauth_fixture.complete_external_step(result) - assert result["data"].get("auth_implementation") == AUTH_DOMAIN + assert result["data"].get("auth_implementation") == ID @pytest.mark.parametrize("disable_setup", [True]) @@ -508,9 +503,7 @@ async def test_import_without_setup(hass, config_credential): """Test import of credentials without setting up the integration.""" with pytest.raises(ValueError): - await async_import_client_credential( - hass, TEST_DOMAIN, TEST_DOMAIN, config_credential - ) + await async_import_client_credential(hass, TEST_DOMAIN, config_credential) # Config flow does not have authentication result = await hass.config_entries.flow.async_init( From 2a4645688381db230923df7bb46579c3b2b187ed Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 6 Apr 2022 07:51:15 -0700 Subject: [PATCH 18/37] Remove debug statement --- homeassistant/components/application_credentials/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 1f8f99ba2f3b4..93d5af74998ba 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -198,7 +198,6 @@ async def _get_platform( err, ) return None - _LOGGER.debug(hasattr(platform, "async_get_authorization_server")) if not hasattr(platform, "async_get_authorization_server"): raise ValueError( "Integration '%s' platform application_credentials did not implement 'async_get_authorization_server'" From 4546569f431060b26fc5c342319e065439b56c04 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 6 Apr 2022 20:03:32 -0700 Subject: [PATCH 19/37] Don't import the same client id multiple times --- .../application_credentials/__init__.py | 10 +++++++++- .../application_credentials/test_init.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 93d5af74998ba..aa2a55ac93468 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util import slugify __all__ = ["ClientCredential", "AuthorizationServer", "async_import_client_credential"] @@ -95,6 +96,13 @@ async def async_delete_item(self, item_id: str) -> None: await super().async_delete_item(item_id) + async def async_import_item(self, info: dict) -> None: + """Import an yaml credential if it does not already exist.""" + suggested_id = self._get_suggested_id(info) + if self.id_manager.has_id(slugify(suggested_id)): + return + await self.async_create_item(info) + def async_client_credentials(self, domain: str) -> dict[str, ClientCredential]: """Return ClientCredentials in storage for the specified domain.""" credentials = {} @@ -143,7 +151,7 @@ async def async_import_client_credential( CONF_CLIENT_ID: credential.client_id, CONF_CLIENT_SECRET: credential.client_secret, } - await storage_collection.async_create_item(item) + await storage_collection.async_import_item(item) async def _async_provide_implementation( diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index eab3d3f85d887..c66bffa086c2e 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -352,6 +352,25 @@ async def test_websocket_import_config(ws_client: ClientFixture): assert await client.cmd_result("list") == [] +@pytest.mark.parametrize("config_credential", [DEVELOPER_CREDENTIAL]) +async def test_import_duplicate_credentials( + hass: HomeAssistant, ws_client: ClientFixture +): + """Exercise duplicate credentials are ignored.""" + + # Import the test credential again and verify it is not imported twice + await async_import_client_credential(hass, TEST_DOMAIN, DEVELOPER_CREDENTIAL) + client = await ws_client() + assert await client.cmd_result("list") == [ + { + CONF_DOMAIN: TEST_DOMAIN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "id": ID, + } + ] + + async def test_config_flow_no_credentials(hass): """Test config flow base case with no credentials registered.""" result = await hass.config_entries.flow.async_init( From d2a029cbc1891a3a2ea9627eb79a7b119704eaf5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 6 Apr 2022 22:54:38 -0700 Subject: [PATCH 20/37] Add auth dependency for local oauth implementation --- homeassistant/components/application_credentials/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/application_credentials/manifest.json b/homeassistant/components/application_credentials/manifest.json index 6c03ade86c976..1f44e0933da36 100644 --- a/homeassistant/components/application_credentials/manifest.json +++ b/homeassistant/components/application_credentials/manifest.json @@ -3,7 +3,7 @@ "name": "Application Credentials", "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/application_credentials", - "dependencies": ["http"], + "dependencies": ["auth"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } From 9d2aa09829faffea82c7226f706cf9d17f605998 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 6 Apr 2022 22:59:10 -0700 Subject: [PATCH 21/37] Revert older oauth2 changes from merge --- homeassistant/helpers/config_entry_oauth2_flow.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 332caddbc0bcc..99cdf9e801bf4 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -328,16 +328,11 @@ def async_register_implementation( @callback def async_register_implementation( hass: HomeAssistant, domain: str, implementation: AbstractOAuth2Implementation -) -> Callable[[], None]: +) -> None: """Register an OAuth2 flow implementation for an integration.""" implementations = hass.data.setdefault(DATA_IMPLEMENTATIONS, {}) implementations.setdefault(domain, {})[implementation.domain] = implementation - def unsub() -> None: - del implementations[domain][implementation.domain] - - return unsub - async def async_get_implementations( hass: HomeAssistant, domain: str From 5935531d2100c032469356719a8dce187192dbca Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Apr 2022 00:32:19 -0700 Subject: [PATCH 22/37] Update homeassistant/components/application_credentials/__init__.py Co-authored-by: Martin Hjelmare --- homeassistant/components/application_credentials/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index aa2a55ac93468..4fafce2432138 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -6,7 +6,7 @@ it. Integrations register an authorization server, and then the APIs are used to add -add one or more client credentials. Integrations may also provide credentials +one or more client credentials. Integrations may also provide credentials from yaml for backwards compatibility. """ from __future__ import annotations From 8a545a9be74e0794ec8f3cdb5cf8e541601323c1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Apr 2022 00:39:55 -0700 Subject: [PATCH 23/37] Move config credential import to its own fixture --- .../application_credentials/test_init.py | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index c66bffa086c2e..f6d842e38124c 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -48,11 +48,18 @@ async def config_credential() -> ClientCredential | None: return None +@pytest.fixture +async def import_config_credential( + hass: HomeAssistant, config_credential: ClientCredential +) -> None: + """Fixture to import the yaml based credential.""" + await async_import_client_credential(hass, TEST_DOMAIN, config_credential) + + async def setup_application_credentials_integration( hass: HomeAssistant, domain: str, authorization_server: AuthorizationServer, - config_credential: ClientCredential | None, ) -> None: """Set up a fake application_credentials integration.""" hass.config.components.add(domain) @@ -63,8 +70,6 @@ async def setup_application_credentials_integration( async_get_authorization_server=AsyncMock(return_value=authorization_server), ), ) - if config_credential: - await async_import_client_credential(hass, domain, config_credential) @pytest.fixture @@ -78,14 +83,13 @@ async def mock_application_credentials_integration( disable_setup: bool, hass: HomeAssistant, authorization_server: AuthorizationServer, - config_credential: ClientCredential | None, ): """Mock a application_credentials integration.""" if disable_setup: return assert await async_setup_component(hass, "application_credentials", {}) await setup_application_credentials_integration( - hass, TEST_DOMAIN, authorization_server, config_credential + hass, TEST_DOMAIN, authorization_server ) @@ -333,7 +337,11 @@ async def test_websocket_delete_item_not_found(ws_client: ClientFixture): @pytest.mark.parametrize("config_credential", [DEVELOPER_CREDENTIAL]) -async def test_websocket_import_config(ws_client: ClientFixture): +async def test_websocket_import_config( + ws_client: ClientFixture, + config_credential: ClientCredential, + import_config_credential: Any, +): """Test websocket list command for an imported credential.""" client = await ws_client() @@ -354,7 +362,10 @@ async def test_websocket_import_config(ws_client: ClientFixture): @pytest.mark.parametrize("config_credential", [DEVELOPER_CREDENTIAL]) async def test_import_duplicate_credentials( - hass: HomeAssistant, ws_client: ClientFixture + hass: HomeAssistant, + ws_client: ClientFixture, + config_credential: ClientCredential, + import_config_credential: Any, ): """Exercise duplicate credentials are ignored.""" @@ -387,7 +398,9 @@ async def test_config_flow_other_domain( ): """Test config flow ignores credentials for another domain.""" await setup_application_credentials_integration( - hass, "other_domain", authorization_server, config_credential=None + hass, + "other_domain", + authorization_server, ) client = await ws_client() await client.cmd_result( @@ -506,7 +519,12 @@ async def test_config_flow_create_delete_credential( @pytest.mark.parametrize("config_credential", [DEVELOPER_CREDENTIAL]) async def test_config_flow_with_config_credential( - hass, hass_client_no_auth, aioclient_mock, oauth_fixture: OAuthFixture + hass, + hass_client_no_auth, + aioclient_mock, + oauth_fixture, + config_credential, + import_config_credential, ): """Test config flow with application credential registered.""" result = await hass.config_entries.flow.async_init( From 377bbf64510cd1f781abf5288a020e5284a7bf93 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Apr 2022 00:40:58 -0700 Subject: [PATCH 24/37] Override the mock_application_credentials_integration fixture instead per test --- .../application_credentials/test_init.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index f6d842e38124c..542c94ba35a22 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -72,21 +72,12 @@ async def setup_application_credentials_integration( ) -@pytest.fixture -def disable_setup() -> bool: - """Fixture to disable mock credentials integration.""" - return False - - @pytest.fixture(autouse=True) async def mock_application_credentials_integration( - disable_setup: bool, hass: HomeAssistant, authorization_server: AuthorizationServer, ): """Mock a application_credentials integration.""" - if disable_setup: - return assert await async_setup_component(hass, "application_credentials", {}) await setup_application_credentials_integration( hass, TEST_DOMAIN, authorization_server @@ -535,7 +526,7 @@ async def test_config_flow_with_config_credential( assert result["data"].get("auth_implementation") == ID -@pytest.mark.parametrize("disable_setup", [True]) +@pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_import_without_setup(hass, config_credential): """Test import of credentials without setting up the integration.""" @@ -550,7 +541,7 @@ async def test_import_without_setup(hass, config_credential): assert result.get("reason") == "missing_configuration" -@pytest.mark.parametrize("disable_setup", [True]) +@pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_websocket_without_platform( hass: HomeAssistant, ws_client: ClientFixture ): @@ -579,7 +570,7 @@ async def test_websocket_without_platform( assert result.get("reason") == "missing_configuration" -@pytest.mark.parametrize("disable_setup", [True]) +@pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_websocket_without_authorization_server( hass: HomeAssistant, ws_client: ClientFixture ): From d02414f909b15341ab8077d9b8381680e3f83b87 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Apr 2022 00:42:11 -0700 Subject: [PATCH 25/37] Update application credentials --- .../components/application_credentials/__init__.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 4fafce2432138..c21bc2b35c6be 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -1,13 +1,9 @@ """The Application Credentials integration. -This integration provides APIs for managing local OAuth credentials on behalf of other -integrations. The preferred approach for all OAuth integrations is to use the cloud -account linking service, and this is the alternative for integrations that can't use -it. - -Integrations register an authorization server, and then the APIs are used to add -one or more client credentials. Integrations may also provide credentials -from yaml for backwards compatibility. +This integration provides APIs for managing local OAuth credentials on behalf +of other integrations. Integrations register an authorization server, and then +the APIs are used to add one or more client credentials. Integrations may also +provide credentials from yaml for backwards compatibility. """ from __future__ import annotations From 9a264ceb429f341aa16b8e4d84c127ab3d7ff7cc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Apr 2022 00:45:14 -0700 Subject: [PATCH 26/37] Add dictionary typing --- .../components/application_credentials/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index c21bc2b35c6be..29a9fd4d04fd4 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -61,7 +61,7 @@ class ApplicationCredentialsStorageCollection(collection.StorageCollection): CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) - async def _process_create_data(self, data: dict) -> dict: + async def _process_create_data(self, data: dict[str, str]) -> dict[str, str]: """Validate the config is valid.""" result = self.CREATE_SCHEMA(data) domain = result[CONF_DOMAIN] @@ -70,11 +70,13 @@ async def _process_create_data(self, data: dict) -> dict: return result @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, str]) -> str: """Suggest an ID based on the config.""" return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}" - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data( + self, data: dict[str, str], update_data: dict[str, str] + ) -> dict[str, str]: """Return a new updated data object.""" raise ValueError("Updates not supported") @@ -92,7 +94,7 @@ async def async_delete_item(self, item_id: str) -> None: await super().async_delete_item(item_id) - async def async_import_item(self, info: dict) -> None: + async def async_import_item(self, info: dict[str, str]) -> None: """Import an yaml credential if it does not already exist.""" suggested_id = self._get_suggested_id(info) if self.id_manager.has_id(slugify(suggested_id)): From 54d028568e73842facd200ba15ff84cdfcf98c79 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Apr 2022 00:47:27 -0700 Subject: [PATCH 27/37] Use f-strings as per feedback --- homeassistant/components/application_credentials/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 29a9fd4d04fd4..ed840f91e03ff 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -66,7 +66,7 @@ async def _process_create_data(self, data: dict[str, str]) -> dict[str, str]: result = self.CREATE_SCHEMA(data) domain = result[CONF_DOMAIN] if not await _get_platform(self.hass, domain): - raise ValueError("No application_credentials platform for %s" % domain) + raise ValueError(f"No application_credentials platform for {domain}") return result @callback @@ -206,6 +206,6 @@ async def _get_platform( return None if not hasattr(platform, "async_get_authorization_server"): raise ValueError( - "Integration '%s' platform application_credentials did not implement 'async_get_authorization_server'" + f"Integration '{integration_domain}' platform application_credentials did not implement 'async_get_authorization_server'" ) return platform From c0d13966c1bc5f439aa9e336520cb0a69e8c5c46 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 18 Apr 2022 20:51:27 -0700 Subject: [PATCH 28/37] Add additional structure needed for an MVP application credential Add additional structure needed for an MVP, including a target component Xbox --- .../application_credentials/__init__.py | 11 +++- .../components/default_config/manifest.json | 1 + .../xbox/application_credentials.py | 14 ++++ .../generated/application_credentials.py | 10 +++ .../helpers/config_entry_oauth2_flow.py | 3 +- script/hassfest/__main__.py | 2 + script/hassfest/application_credentials.py | 64 +++++++++++++++++++ 7 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/xbox/application_credentials.py create mode 100644 homeassistant/generated/application_credentials.py create mode 100644 script/hassfest/application_credentials.py diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index ed840f91e03ff..756000e157e47 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -152,6 +152,15 @@ async def async_import_client_credential( await storage_collection.async_import_item(item) +class AuthImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): + """Application Credentials local oauth2 implementation.""" + + @property + def name(self) -> str: + """Name of the implementation.""" + return self.client_id + + async def _async_provide_implementation( hass: HomeAssistant, domain: str ) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: @@ -165,7 +174,7 @@ async def _async_provide_implementation( storage_collection = hass.data[DOMAIN][DATA_STORAGE] credentials = storage_collection.async_client_credentials(domain) return [ - config_entry_oauth2_flow.LocalOAuth2Implementation( + AuthImplementation( hass, auth_domain, credential.client_id, diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 1ab827529c61d..1742092cc7098 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -3,6 +3,7 @@ "name": "Default Config", "documentation": "https://www.home-assistant.io/integrations/default_config", "dependencies": [ + "application_credentials", "automation", "cloud", "counter", diff --git a/homeassistant/components/xbox/application_credentials.py b/homeassistant/components/xbox/application_credentials.py new file mode 100644 index 0000000000000..2e3d7f8a6a054 --- /dev/null +++ b/homeassistant/components/xbox/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for xbox.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py new file mode 100644 index 0000000000000..ec6c1886e0ad0 --- /dev/null +++ b/homeassistant/generated/application_credentials.py @@ -0,0 +1,10 @@ +"""Automatically generated by hassfest. + +To update, run python3 -m script.hassfest +""" + +# fmt: off + +APPLICATION_CREDENTIALS = [ + "xbox" +] diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 99cdf9e801bf4..d0aaca71304a3 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -347,8 +347,7 @@ async def async_get_implementations( return registered registered = dict(registered) - - for get_impl in hass.data[DATA_PROVIDERS].values(): + for get_impl in list(hass.data[DATA_PROVIDERS].values()): for impl in await get_impl(hass, domain): registered[impl.domain] = impl diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index c6a9799a502c6..889cad2a497f1 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -5,6 +5,7 @@ from time import monotonic from . import ( + application_credentials, codeowners, config_flow, coverage, @@ -25,6 +26,7 @@ from .model import Config, Integration INTEGRATION_PLUGINS = [ + application_credentials, codeowners, config_flow, dependencies, diff --git a/script/hassfest/application_credentials.py b/script/hassfest/application_credentials.py new file mode 100644 index 0000000000000..22c06e236344e --- /dev/null +++ b/script/hassfest/application_credentials.py @@ -0,0 +1,64 @@ +"""Generate application_credentials data.""" +from __future__ import annotations + +import json + +from .model import Config, Integration + +BASE = """ +\"\"\"Automatically generated by hassfest. + +To update, run python3 -m script.hassfest +\"\"\" + +# fmt: off + +APPLICATION_CREDENTIALS = {} +""".strip() + + +def generate_and_validate(integrations: dict[str, Integration], config: Config): + """Validate and generate config flow data.""" + + match_list = [] + + for domain in sorted(integrations): + integration = integrations[domain] + application_credentials_file = integration.path / "application_credentials.py" + if not application_credentials_file.is_file(): + continue + + match_list.append(domain) + + return BASE.format(json.dumps(match_list, indent=4)) + + +def validate(integrations: dict[str, Integration], config: Config): + """Validate application_credentials data.""" + application_credentials_path = ( + config.root / "homeassistant/generated/application_credentials.py" + ) + config.cache["application_credentials"] = content = generate_and_validate( + integrations, config + ) + + if config.specific_integrations: + return + + with open(str(application_credentials_path)) as fp: + if fp.read().strip() != content: + config.add_error( + "application_credentials", + "File application_credentials.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + return + + +def generate(integrations: dict[str, Integration], config: Config): + """Generate application_credentials data.""" + application_credentials_path = ( + config.root / "homeassistant/generated/application_credentials.py" + ) + with open(str(application_credentials_path), "w") as fp: + fp.write(f"{config.cache['application_credentials']}\n") From 59bc219e1594275428e1827aefa2a88698c437ca Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 23 Apr 2022 11:18:33 -0700 Subject: [PATCH 29/37] Add websocket to list supported integrations for frontend selector --- .../application_credentials/__init__.py | 18 +++++++++++++++++- .../application_credentials/manifest.json | 2 +- .../application_credentials/test_init.py | 12 +++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 756000e157e47..aace5d2ac6e1b 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -9,12 +9,15 @@ from dataclasses import dataclass import logging -from typing import Protocol +from typing import Any, Protocol import voluptuous as vol +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DOMAIN, CONF_ID from homeassistant.core import HomeAssistant, callback +from homeassistant.generated.application_credentials import APPLICATION_CREDENTIALS from homeassistant.helpers import collection, config_entry_oauth2_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import Store @@ -130,6 +133,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) + websocket_api.async_register_command(hass, handle_integration_list) + config_entry_oauth2_flow.async_add_implementation_provider( hass, DOMAIN, _async_provide_implementation ) @@ -218,3 +223,14 @@ async def _get_platform( f"Integration '{integration_domain}' platform application_credentials did not implement 'async_get_authorization_server'" ) return platform + + +@websocket_api.websocket_command( + {vol.Required("type"): "application_credentials/integrations/list"} +) +@websocket_api.async_response +async def handle_integration_list( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle integrations command.""" + connection.send_result(msg["id"], APPLICATION_CREDENTIALS) diff --git a/homeassistant/components/application_credentials/manifest.json b/homeassistant/components/application_credentials/manifest.json index 1f44e0933da36..9a8abc16c3693 100644 --- a/homeassistant/components/application_credentials/manifest.json +++ b/homeassistant/components/application_credentials/manifest.json @@ -3,7 +3,7 @@ "name": "Application Credentials", "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/application_credentials", - "dependencies": ["auth"], + "dependencies": ["auth", "websocket_api"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 542c94ba35a22..f76ad25bc8647 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -148,7 +148,7 @@ async def complete_external_step( result = await self.hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result.get("title") == "Configuration.yaml" + assert result.get("title") == self.client_id assert "data" in result assert "token" in result["data"] return result @@ -605,3 +605,13 @@ async def test_websocket_without_authorization_server( await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} ) + + +async def test_websocket_integration_list(ws_client: ClientFixture): + """Test websocket integration list command.""" + client = await ws_client() + with patch( + "homeassistant.components.application_credentials.APPLICATION_CREDENTIALS", + ["example1", "example2"], + ): + assert await client.cmd_result("integrations/list") == ["example1", "example2"] From bb5bafbbbd4bd9554eea54079c06fb7f7a0bd64d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 25 Apr 2022 19:21:11 -0700 Subject: [PATCH 30/37] Application credentials config --- homeassistant/components/application_credentials/__init__.py | 4 ++-- tests/components/application_credentials/test_init.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index aace5d2ac6e1b..84c3cacd5003a 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -226,11 +226,11 @@ async def _get_platform( @websocket_api.websocket_command( - {vol.Required("type"): "application_credentials/integrations/list"} + {vol.Required("type"): "application_credentials/config"} ) @websocket_api.async_response async def handle_integration_list( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" - connection.send_result(msg["id"], APPLICATION_CREDENTIALS) + connection.send_result(msg["id"], {"domains": APPLICATION_CREDENTIALS}) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index f76ad25bc8647..9f8e23bf9fe57 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -614,4 +614,6 @@ async def test_websocket_integration_list(ws_client: ClientFixture): "homeassistant.components.application_credentials.APPLICATION_CREDENTIALS", ["example1", "example2"], ): - assert await client.cmd_result("integrations/list") == ["example1", "example2"] + assert await client.cmd_result("config") == { + "domains": ["example1", "example2"] + } From 3e737d61e666c14dde7096d2b4dd7357dc9e310b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 26 Apr 2022 19:50:11 -0700 Subject: [PATCH 31/37] Import xbox credentials --- homeassistant/components/xbox/__init__.py | 17 +++++++---------- homeassistant/components/xbox/manifest.json | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 0466d0191cf66..2b5772dd0baf9 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -20,6 +20,7 @@ SmartglassConsoleStatus, ) +from homeassistant.components import application_credentials from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform from homeassistant.core import HomeAssistant @@ -31,8 +32,8 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import api, config_flow -from .const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from . import api +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -63,15 +64,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True - config_flow.OAuth2FlowHandler.async_register_implementation( + await application_credentials.async_import_client_credential( hass, - config_entry_oauth2_flow.LocalOAuth2Implementation( - hass, - DOMAIN, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, + DOMAIN, + application_credentials.ClientCredential( + config[DOMAIN][CONF_CLIENT_ID], config[DOMAIN][CONF_CLIENT_SECRET] ), ) diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 432b3e841002b..5adfa54a90105 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xbox", "requirements": ["xbox-webapi==2.0.11"], - "dependencies": ["auth"], + "dependencies": ["auth", "application_credentials"], "codeowners": ["@hunterjm"], "iot_class": "cloud_polling" } From d2ea24e5b48da08858ffb4f9d02fed9d498e1315 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 26 Apr 2022 19:55:27 -0700 Subject: [PATCH 32/37] Remove unnecessary async calls --- homeassistant/components/application_credentials/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 84c3cacd5003a..b5be92cd1bae4 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -228,8 +228,8 @@ async def _get_platform( @websocket_api.websocket_command( {vol.Required("type"): "application_credentials/config"} ) -@websocket_api.async_response -async def handle_integration_list( +@callback +def handle_integration_list( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" From 7880e2dda2be6fe242910fcb1409d5a3e2065f8d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 27 Apr 2022 07:23:22 -0700 Subject: [PATCH 33/37] Update script/hassfest/application_credentials.py Co-authored-by: Martin Hjelmare --- script/hassfest/application_credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/hassfest/application_credentials.py b/script/hassfest/application_credentials.py index 22c06e236344e..5913d12950dd8 100644 --- a/script/hassfest/application_credentials.py +++ b/script/hassfest/application_credentials.py @@ -17,7 +17,7 @@ """.strip() -def generate_and_validate(integrations: dict[str, Integration], config: Config): +def generate_and_validate(integrations: dict[str, Integration], config: Config) -> str: """Validate and generate config flow data.""" match_list = [] From 44d0627d2a90537c59fe65c3ca9063ef7e731b0a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 27 Apr 2022 07:23:34 -0700 Subject: [PATCH 34/37] Update script/hassfest/application_credentials.py Co-authored-by: Martin Hjelmare --- script/hassfest/application_credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/hassfest/application_credentials.py b/script/hassfest/application_credentials.py index 5913d12950dd8..051dcc5779497 100644 --- a/script/hassfest/application_credentials.py +++ b/script/hassfest/application_credentials.py @@ -33,7 +33,7 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config) return BASE.format(json.dumps(match_list, indent=4)) -def validate(integrations: dict[str, Integration], config: Config): +def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate application_credentials data.""" application_credentials_path = ( config.root / "homeassistant/generated/application_credentials.py" From 6d60e2a64dd2c3066113abd0acf86a0bf014f3dd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 27 Apr 2022 07:23:56 -0700 Subject: [PATCH 35/37] Update script/hassfest/application_credentials.py Co-authored-by: Martin Hjelmare --- script/hassfest/application_credentials.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/script/hassfest/application_credentials.py b/script/hassfest/application_credentials.py index 051dcc5779497..357e9a2e471c4 100644 --- a/script/hassfest/application_credentials.py +++ b/script/hassfest/application_credentials.py @@ -45,14 +45,12 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - with open(str(application_credentials_path)) as fp: - if fp.read().strip() != content: - config.add_error( - "application_credentials", - "File application_credentials.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) - return + if application_credentials_path.read_text(encoding="utf-8").strip() != content: + config.add_error( + "application_credentials", + "File application_credentials.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config): From ee3aefd9c2e5d6e53c78b74f28f8400d5dee89c9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 27 Apr 2022 07:24:04 -0700 Subject: [PATCH 36/37] Update script/hassfest/application_credentials.py Co-authored-by: Martin Hjelmare --- script/hassfest/application_credentials.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/script/hassfest/application_credentials.py b/script/hassfest/application_credentials.py index 357e9a2e471c4..87a277bb2b825 100644 --- a/script/hassfest/application_credentials.py +++ b/script/hassfest/application_credentials.py @@ -58,5 +58,6 @@ def generate(integrations: dict[str, Integration], config: Config): application_credentials_path = ( config.root / "homeassistant/generated/application_credentials.py" ) - with open(str(application_credentials_path), "w") as fp: - fp.write(f"{config.cache['application_credentials']}\n") + application_credentials_path.write_text( + f"{config.cache['application_credentials']}\n", encoding="utf-8" + ) From c9b238035c33fdbfd7ffafc1034b488cd81d6477 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 30 Apr 2022 00:07:35 -0700 Subject: [PATCH 37/37] Import credentials with a fixed auth domain Resolve an issue with compatibility of exisiting config entries when importing client credentials --- .../components/application_credentials/__init__.py | 8 +++++++- tests/components/application_credentials/test_init.py | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index b5be92cd1bae4..cc5ed5e44bbd8 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -34,11 +34,13 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 DATA_STORAGE = "storage" +CONF_AUTH_DOMAIN = "auth_domain" CREATE_FIELDS = { vol.Required(CONF_DOMAIN): cv.string, vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_AUTH_DOMAIN): cv.string, } UPDATE_FIELDS: dict = {} # Not supported @@ -110,7 +112,10 @@ def async_client_credentials(self, domain: str) -> dict[str, ClientCredential]: for item in self.async_items(): if item[CONF_DOMAIN] != domain: continue - credentials[item[CONF_ID]] = ClientCredential( + auth_domain = ( + item[CONF_AUTH_DOMAIN] if CONF_AUTH_DOMAIN in item else item[CONF_ID] + ) + credentials[auth_domain] = ClientCredential( item[CONF_CLIENT_ID], item[CONF_CLIENT_SECRET] ) return credentials @@ -153,6 +158,7 @@ async def async_import_client_credential( CONF_DOMAIN: domain, CONF_CLIENT_ID: credential.client_id, CONF_CLIENT_SECRET: credential.client_secret, + CONF_AUTH_DOMAIN: domain, } await storage_collection.async_import_item(item) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 9f8e23bf9fe57..31cf45f2b54b2 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -12,6 +12,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.application_credentials import ( + CONF_AUTH_DOMAIN, DOMAIN, AuthorizationServer, ClientCredential, @@ -343,6 +344,7 @@ async def test_websocket_import_config( CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET, "id": ID, + CONF_AUTH_DOMAIN: TEST_DOMAIN, } ] @@ -369,6 +371,7 @@ async def test_import_duplicate_credentials( CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET, "id": ID, + CONF_AUTH_DOMAIN: TEST_DOMAIN, } ] @@ -523,7 +526,8 @@ async def test_config_flow_with_config_credential( ) assert result.get("type") == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP result = await oauth_fixture.complete_external_step(result) - assert result["data"].get("auth_implementation") == ID + # Uses the imported auth domain for compatibility + assert result["data"].get("auth_implementation") == TEST_DOMAIN @pytest.mark.parametrize("mock_application_credentials_integration", [None])