Add application credentials platform (#69148)

* 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

* Fix pydoc text

* Remove translations and update owners

* Update homeassistant/components/developer_credentials/__init__.py

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Update homeassistant/components/developer_credentials/__init__.py

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Remove _async_get_developer_credential

* Rename to application credentials platform

* Fix race condition and add import support

* Increase code coverage (92%)

* Increase test coverage 93%

* Increase test coverage (94%)

* Increase test coverage (97%)

* Increase test covearge (98%)

* Increase test coverage (99%)

* Increase test coverage (100%)

* Remove http router frozen comment

* Remove auth domain override on import

* Remove debug statement

* Don't import the same client id multiple times

* Add auth dependency for local oauth implementation

* Revert older oauth2 changes from merge

* Update homeassistant/components/application_credentials/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Move config credential import to its own fixture

* Override the mock_application_credentials_integration fixture instead per test

* Update application credentials

* Add dictionary typing

* Use f-strings as per feedback

* Add additional structure needed for an MVP application credential

Add additional structure needed for an MVP, including a target
component Xbox

* Add websocket to list supported integrations for frontend selector

* Application credentials config

* Import xbox credentials

* Remove unnecessary async calls

* Update script/hassfest/application_credentials.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update script/hassfest/application_credentials.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update script/hassfest/application_credentials.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update script/hassfest/application_credentials.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Import credentials with a fixed auth domain

Resolve an issue with compatibility of exisiting config entries when importing
client credentials

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-04-30 08:06:43 -07:00 committed by GitHub
parent ae8604d429
commit 00b5d30e24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1006 additions and 25 deletions

View file

@ -0,0 +1,242 @@
"""The Application Credentials integration.
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 dataclasses import dataclass
import logging
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
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"]
_LOGGER = logging.getLogger(__name__)
DOMAIN = "application_credentials"
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
@dataclass
class ClientCredential:
"""Represent an OAuth client credential."""
client_id: str
client_secret: str
@dataclass
class AuthorizationServer:
"""Represent an OAuth2 Authorization Server."""
authorize_url: str
token_url: str
class ApplicationCredentialsStorageCollection(collection.StorageCollection):
"""Application credential collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
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]
if not await _get_platform(self.hass, domain):
raise ValueError(f"No application_credentials platform for {domain}")
return result
@callback
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[str, str], update_data: dict[str, str]
) -> dict[str, str]:
"""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)
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)):
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 = {}
for item in self.async_items():
if item[CONF_DOMAIN] != domain:
continue
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
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Application Credentials."""
hass.data[DOMAIN] = {}
id_manager = collection.IDManager()
storage_collection = ApplicationCredentialsStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
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)
websocket_api.async_register_command(hass, handle_integration_list)
config_entry_oauth2_flow.async_add_implementation_provider(
hass, DOMAIN, _async_provide_implementation
)
return True
async def async_import_client_credential(
hass: HomeAssistant, domain: str, credential: ClientCredential
) -> None:
"""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]
item = {
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)
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]:
"""Return registered OAuth implementations."""
platform = await _get_platform(hass, domain)
if not platform:
return []
authorization_server = await platform.async_get_authorization_server(hass)
storage_collection = hass.data[DOMAIN][DATA_STORAGE]
credentials = storage_collection.async_client_credentials(domain)
return [
AuthImplementation(
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):
"""Define the format that application_credentials platforms can have."""
async def async_get_authorization_server(
self, hass: HomeAssistant
) -> AuthorizationServer:
"""Return authorization server."""
async def _get_platform(
hass: HomeAssistant, integration_domain: str
) -> ApplicationCredentialsProtocol | None:
"""Register an application_credentials platform."""
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(
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/config"}
)
@callback
def handle_integration_list(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle integrations command."""
connection.send_result(msg["id"], {"domains": APPLICATION_CREDENTIALS})

View file

@ -0,0 +1,9 @@
{
"domain": "application_credentials",
"name": "Application Credentials",
"config_flow": false,
"documentation": "https://www.home-assistant.io/integrations/application_credentials",
"dependencies": ["auth", "websocket_api"],
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal"
}

View file

@ -0,0 +1,3 @@
{
"title": "Application Credentials"
}