Use HA native OAuth2 flow for google assistant components (#16848)

* Use HA native OAuth2 flow for google assistant components

* Lint

* Force breaking changes

* Fix CONFIG_SCHEMA
This commit is contained in:
Jason Hu 2018-09-25 23:57:55 -07:00 committed by Paulus Schoutsen
parent 3cba2e695c
commit 92a5068977
6 changed files with 43 additions and 162 deletions

View file

@ -14,20 +14,18 @@ import async_timeout
import voluptuous as vol
# Typing imports
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.const import CONF_NAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN,
CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS,
DEFAULT_EXPOSED_DOMAINS, CONF_AGENT_USER_ID, CONF_API_KEY,
DOMAIN, CONF_PROJECT_ID, CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT,
CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_API_KEY,
SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG,
CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT
)
from .auth import GoogleAssistantAuthView
from .http import async_register_http
_LOGGER = logging.getLogger(__name__)
@ -43,34 +41,28 @@ ENTITY_SCHEMA = vol.Schema({
vol.Optional(CONF_ROOM_HINT): cv.string
})
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: {
vol.Required(CONF_PROJECT_ID): cv.string,
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_ACCESS_TOKEN): cv.string,
vol.Optional(CONF_EXPOSE_BY_DEFAULT,
default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean,
vol.Optional(CONF_EXPOSED_DOMAINS,
default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list,
vol.Optional(CONF_AGENT_USER_ID,
default=DEFAULT_AGENT_USER_ID): cv.string,
vol.Optional(CONF_API_KEY): cv.string,
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}
}
},
extra=vol.ALLOW_EXTRA)
GOOGLE_ASSISTANT_SCHEMA = vol.Schema({
vol.Required(CONF_PROJECT_ID): cv.string,
vol.Optional(CONF_EXPOSE_BY_DEFAULT,
default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean,
vol.Optional(CONF_EXPOSED_DOMAINS,
default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list,
vol.Optional(CONF_API_KEY): cv.string,
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}
}, extra=vol.PREVENT_EXTRA)
CONFIG_SCHEMA = vol.Schema({
DOMAIN: GOOGLE_ASSISTANT_SCHEMA
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
"""Activate Google Actions component."""
config = yaml_config.get(DOMAIN, {})
agent_user_id = config.get(CONF_AGENT_USER_ID)
api_key = config.get(CONF_API_KEY)
hass.http.register_view(GoogleAssistantAuthView(hass, config))
async_register_http(hass, config)
async def request_sync_service_handler(call):
async def request_sync_service_handler(call: ServiceCall):
"""Handle request sync service calls."""
websession = async_get_clientsession(hass)
try:
@ -78,7 +70,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
res = await websession.post(
REQUEST_SYNC_BASE_URL,
params={'key': api_key},
json={'agent_user_id': agent_user_id})
json={'agent_user_id': call.context.user_id})
_LOGGER.info("Submitted request_sync request to Google")
res.raise_for_status()
except aiohttp.ClientResponseError:

View file

@ -1,83 +0,0 @@
"""Google Assistant OAuth View."""
import logging
from typing import Dict, Any
# Typing imports
# if False:
from aiohttp.web import Request, Response
from homeassistant.core import HomeAssistant
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import (
HTTP_BAD_REQUEST,
HTTP_UNAUTHORIZED,
HTTP_MOVED_PERMANENTLY,
)
from .const import (
GOOGLE_ASSISTANT_API_ENDPOINT,
CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN
)
BASE_OAUTH_URL = 'https://oauth-redirect.googleusercontent.com'
REDIRECT_TEMPLATE_URL = \
'{}/r/{}#access_token={}&token_type=bearer&state={}'
_LOGGER = logging.getLogger(__name__)
class GoogleAssistantAuthView(HomeAssistantView):
"""Handle Google Actions auth requests."""
url = GOOGLE_ASSISTANT_API_ENDPOINT + '/auth'
name = 'api:google_assistant:auth'
requires_auth = False
def __init__(self, hass: HomeAssistant, cfg: Dict[str, Any]) -> None:
"""Initialize instance of the view."""
super().__init__()
self.project_id = cfg.get(CONF_PROJECT_ID)
self.client_id = cfg.get(CONF_CLIENT_ID)
self.access_token = cfg.get(CONF_ACCESS_TOKEN)
async def get(self, request: Request) -> Response:
"""Handle oauth token request."""
query = request.query
redirect_uri = query.get('redirect_uri')
if not redirect_uri:
msg = 'missing redirect_uri field'
_LOGGER.warning(msg)
return self.json_message(msg, status_code=HTTP_BAD_REQUEST)
if self.project_id not in redirect_uri:
msg = 'missing project_id in redirect_uri'
_LOGGER.warning(msg)
return self.json_message(msg, status_code=HTTP_BAD_REQUEST)
state = query.get('state')
if not state:
msg = 'oauth request missing state'
_LOGGER.warning(msg)
return self.json_message(msg, status_code=HTTP_BAD_REQUEST)
client_id = query.get('client_id')
if self.client_id != client_id:
msg = 'invalid client id'
_LOGGER.warning(msg)
return self.json_message(msg, status_code=HTTP_UNAUTHORIZED)
generated_url = redirect_url(self.project_id, self.access_token, state)
_LOGGER.info('user login in from Google Assistant')
return self.json_message(
'redirect success',
status_code=HTTP_MOVED_PERMANENTLY,
headers={'Location': generated_url})
def redirect_url(project_id: str, access_token: str, state: str) -> str:
"""Generate the redirect format for the oauth request."""
return REDIRECT_TEMPLATE_URL.format(BASE_OAUTH_URL, project_id,
access_token, state)

View file

@ -8,10 +8,7 @@ CONF_ENTITY_CONFIG = 'entity_config'
CONF_EXPOSE_BY_DEFAULT = 'expose_by_default'
CONF_EXPOSED_DOMAINS = 'exposed_domains'
CONF_PROJECT_ID = 'project_id'
CONF_ACCESS_TOKEN = 'access_token'
CONF_CLIENT_ID = 'client_id'
CONF_ALIASES = 'aliases'
CONF_AGENT_USER_ID = 'agent_user_id'
CONF_API_KEY = 'api_key'
CONF_ROOM_HINT = 'room'

View file

@ -6,7 +6,6 @@ https://home-assistant.io/components/google_assistant/
"""
import logging
from aiohttp.hdrs import AUTHORIZATION
from aiohttp.web import Request, Response
# Typing imports
@ -15,10 +14,8 @@ from homeassistant.core import callback
from .const import (
GOOGLE_ASSISTANT_API_ENDPOINT,
CONF_ACCESS_TOKEN,
CONF_EXPOSE_BY_DEFAULT,
CONF_EXPOSED_DOMAINS,
CONF_AGENT_USER_ID,
CONF_ENTITY_CONFIG,
CONF_EXPOSE,
)
@ -31,10 +28,8 @@ _LOGGER = logging.getLogger(__name__)
@callback
def async_register_http(hass, cfg):
"""Register HTTP views for Google Assistant."""
access_token = cfg.get(CONF_ACCESS_TOKEN)
expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT)
exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS)
agent_user_id = cfg.get(CONF_AGENT_USER_ID)
entity_config = cfg.get(CONF_ENTITY_CONFIG) or {}
def is_exposed(entity) -> bool:
@ -57,9 +52,8 @@ def async_register_http(hass, cfg):
return is_default_exposed or explicit_expose
gass_config = Config(is_exposed, agent_user_id, entity_config)
hass.http.register_view(
GoogleAssistantView(access_token, gass_config))
GoogleAssistantView(is_exposed, entity_config))
class GoogleAssistantView(HomeAssistantView):
@ -67,20 +61,19 @@ class GoogleAssistantView(HomeAssistantView):
url = GOOGLE_ASSISTANT_API_ENDPOINT
name = 'api:google_assistant'
requires_auth = False # Uses access token from oauth flow
requires_auth = True
def __init__(self, access_token, gass_config):
def __init__(self, is_exposed, entity_config):
"""Initialize the Google Assistant request handler."""
self.access_token = access_token
self.gass_config = gass_config
self.is_exposed = is_exposed
self.entity_config = entity_config
async def post(self, request: Request) -> Response:
"""Handle Google Assistant requests."""
auth = request.headers.get(AUTHORIZATION, None)
if 'Bearer {}'.format(self.access_token) != auth:
return self.json_message("missing authorization", status_code=401)
message = await request.json() # type: dict
config = Config(self.is_exposed,
request['hass_user'].id,
self.entity_config)
result = await async_handle_message(
request.app['hass'], self.gass_config, message)
request.app['hass'], config, message)
return self.json(result)

View file

@ -23,7 +23,12 @@ HA_HEADERS = {
PROJECT_ID = 'hasstest-1234'
CLIENT_ID = 'helloworld'
ACCESS_TOKEN = 'superdoublesecret'
AUTH_HEADER = {AUTHORIZATION: 'Bearer {}'.format(ACCESS_TOKEN)}
@pytest.fixture
def auth_header(hass_access_token):
"""Generate an HTTP header with bearer token authorization."""
return {AUTHORIZATION: 'Bearer {}'.format(hass_access_token)}
@pytest.fixture
@ -33,8 +38,6 @@ def assistant_client(loop, hass, aiohttp_client):
setup.async_setup_component(hass, 'google_assistant', {
'google_assistant': {
'project_id': PROJECT_ID,
'client_id': CLIENT_ID,
'access_token': ACCESS_TOKEN,
'entity_config': {
'light.ceiling_lights': {
'aliases': ['top lights', 'ceiling lights'],
@ -97,31 +100,14 @@ def hass_fixture(loop, hass):
@asyncio.coroutine
def test_auth(assistant_client):
"""Test the auth process."""
result = yield from assistant_client.get(
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT + '/auth',
params={
'redirect_uri':
'http://testurl/r/{}'.format(PROJECT_ID),
'client_id': CLIENT_ID,
'state': 'random1234',
},
allow_redirects=False)
assert result.status == 301
loc = result.headers.get('Location')
assert ACCESS_TOKEN in loc
@asyncio.coroutine
def test_sync_request(hass_fixture, assistant_client):
def test_sync_request(hass_fixture, assistant_client, auth_header):
"""Test a sync request."""
reqid = '5711642932632160983'
data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
result = yield from assistant_client.post(
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
data=json.dumps(data),
headers=AUTH_HEADER)
headers=auth_header)
assert result.status == 200
body = yield from result.json()
assert body.get('requestId') == reqid
@ -141,7 +127,7 @@ def test_sync_request(hass_fixture, assistant_client):
@asyncio.coroutine
def test_query_request(hass_fixture, assistant_client):
def test_query_request(hass_fixture, assistant_client, auth_header):
"""Test a query request."""
reqid = '5711642932632160984'
data = {
@ -165,7 +151,7 @@ def test_query_request(hass_fixture, assistant_client):
result = yield from assistant_client.post(
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
data=json.dumps(data),
headers=AUTH_HEADER)
headers=auth_header)
assert result.status == 200
body = yield from result.json()
assert body.get('requestId') == reqid
@ -180,7 +166,7 @@ def test_query_request(hass_fixture, assistant_client):
@asyncio.coroutine
def test_query_climate_request(hass_fixture, assistant_client):
def test_query_climate_request(hass_fixture, assistant_client, auth_header):
"""Test a query request."""
reqid = '5711642932632160984'
data = {
@ -200,7 +186,7 @@ def test_query_climate_request(hass_fixture, assistant_client):
result = yield from assistant_client.post(
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
data=json.dumps(data),
headers=AUTH_HEADER)
headers=auth_header)
assert result.status == 200
body = yield from result.json()
assert body.get('requestId') == reqid
@ -229,7 +215,7 @@ def test_query_climate_request(hass_fixture, assistant_client):
@asyncio.coroutine
def test_query_climate_request_f(hass_fixture, assistant_client):
def test_query_climate_request_f(hass_fixture, assistant_client, auth_header):
"""Test a query request."""
# Mock demo devices as fahrenheit to see if we convert to celsius
hass_fixture.config.units.temperature_unit = const.TEMP_FAHRENHEIT
@ -256,7 +242,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client):
result = yield from assistant_client.post(
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
data=json.dumps(data),
headers=AUTH_HEADER)
headers=auth_header)
assert result.status == 200
body = yield from result.json()
assert body.get('requestId') == reqid
@ -286,7 +272,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client):
@asyncio.coroutine
def test_execute_request(hass_fixture, assistant_client):
def test_execute_request(hass_fixture, assistant_client, auth_header):
"""Test an execute request."""
reqid = '5711642932632160985'
data = {
@ -358,7 +344,7 @@ def test_execute_request(hass_fixture, assistant_client):
result = yield from assistant_client.post(
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
data=json.dumps(data),
headers=AUTH_HEADER)
headers=auth_header)
assert result.status == 200
body = yield from result.json()
assert body.get('requestId') == reqid

View file

@ -5,7 +5,6 @@ from homeassistant.setup import async_setup_component
from homeassistant.components import google_assistant as ga
GA_API_KEY = "Agdgjsj399sdfkosd932ksd"
GA_AGENT_USER_ID = "testid"
@asyncio.coroutine
@ -17,9 +16,6 @@ def test_request_sync_service(aioclient_mock, hass):
yield from async_setup_component(hass, 'google_assistant', {
'google_assistant': {
'project_id': 'test_project',
'client_id': 'r7328kwdsdfsdf03223409',
'access_token': '8wdsfjsf932492342349234',
'agent_user_id': GA_AGENT_USER_ID,
'api_key': GA_API_KEY
}})