-
-
Notifications
You must be signed in to change notification settings - Fork 38.2k
Add application credentials platform #69148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
allenporter
merged 37 commits into
home-assistant:dev
from
allenporter:developer_credentials
Apr 30, 2022
Merged
Changes from 36 commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
e055c33
Initial developer credentials scaffolding
allenporter b21851f
Fix pydoc text
allenporter 8000d0a
Remove translations and update owners
allenporter e241494
Update homeassistant/components/developer_credentials/__init__.py
allenporter e9aecf0
Update homeassistant/components/developer_credentials/__init__.py
allenporter db7c763
Remove _async_get_developer_credential
allenporter 2df100a
Rename to application credentials platform
allenporter 655e821
Fix race condition and add import support
allenporter cd6b9bb
Increase code coverage (92%)
allenporter 4134ce6
Increase test coverage 93%
allenporter ef35a28
Increase test coverage (94%)
allenporter f55f3ad
Increase test coverage (97%)
allenporter c4b8363
Increase test covearge (98%)
allenporter 5d0d010
Increase test coverage (99%)
allenporter 095e2df
Increase test coverage (100%)
allenporter 25aa45e
Remove http router frozen comment
allenporter 53a2f4f
Remove auth domain override on import
allenporter 2a46456
Remove debug statement
allenporter 4546569
Don't import the same client id multiple times
allenporter d2a029c
Add auth dependency for local oauth implementation
allenporter 9d2aa09
Revert older oauth2 changes from merge
allenporter 5935531
Update homeassistant/components/application_credentials/__init__.py
allenporter 8a545a9
Move config credential import to its own fixture
allenporter 377bbf6
Override the mock_application_credentials_integration fixture instead…
allenporter d02414f
Update application credentials
allenporter 9a264ce
Add dictionary typing
allenporter 54d0285
Use f-strings as per feedback
allenporter c0d1396
Add additional structure needed for an MVP application credential
allenporter 59bc219
Add websocket to list supported integrations for frontend selector
allenporter bb5bafb
Application credentials config
allenporter 3e737d6
Import xbox credentials
allenporter d2ea24e
Remove unnecessary async calls
allenporter 7880e2d
Update script/hassfest/application_credentials.py
allenporter 44d0627
Update script/hassfest/application_credentials.py
allenporter 6d60e2a
Update script/hassfest/application_credentials.py
allenporter ee3aefd
Update script/hassfest/application_credentials.py
allenporter c9b2380
Import credentials with a fixed auth domain
allenporter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
236 changes: 236 additions & 0 deletions
236
homeassistant/components/application_credentials/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| """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" | ||
|
|
||
| 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 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 | ||
| credentials[item[CONF_ID]] = 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, | ||
| } | ||
| 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}) | ||
9 changes: 9 additions & 0 deletions
9
homeassistant/components/application_credentials/manifest.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } |
3 changes: 3 additions & 0 deletions
3
homeassistant/components/application_credentials/strings.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "title": "Application Credentials" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| """Automatically generated by hassfest. | ||
|
|
||
| To update, run python3 -m script.hassfest | ||
| """ | ||
|
|
||
| # fmt: off | ||
|
|
||
| APPLICATION_CREDENTIALS = [ | ||
| "xbox" | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.